diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3bf2a126 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +dist +*/node_modules +Dockerfile* diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..23002306 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,11 @@ +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view + +# Run prettier (https://github.com/binwiederhier/ntfy/pull/746) +6f6a2d1f693070bf72e89d86748080e4825c9164 +c87549e71a10bc789eac8036078228f06e515a8e +ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 +2e27f58963feb9e4d1c573d4745d07770777fa7d + +# Run eslint (https://github.com/binwiederhier/ntfy/pull/748) +f558b4dbe9bb5b9e0e87fada1215de2558353173 +8319f1cf26113167fb29fe12edaff5db74caf35f diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..e1eb0619 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [binwiederhier] +liberapay: ntfy diff --git a/.github/ISSUE_TEMPLATE/1_bug_report.md b/.github/ISSUE_TEMPLATE/1_bug_report.md new file mode 100644 index 00000000..90ff2b27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_bug_report.md @@ -0,0 +1,26 @@ +--- +name: 🐛 Bug Report +about: Report any errors and problems +title: '' +labels: '🪲 bug' +assignees: '' + +--- + +:lady_beetle: **Describe the bug** + + +:computer: **Components impacted** + + +:bulb: **Screenshots and/or logs** + + +:crystal_ball: **Additional context** + diff --git a/.github/ISSUE_TEMPLATE/2_enhancement_request.md b/.github/ISSUE_TEMPLATE/2_enhancement_request.md new file mode 100644 index 00000000..790ded12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_enhancement_request.md @@ -0,0 +1,26 @@ +--- +name: 💡 Feature/Enhancement Request +about: Got a great idea? Let us know! +title: '' +labels: 'enhancement' +assignees: '' + +--- + + + +:bulb: **Idea** + + +:computer: **Target components** + + + diff --git a/.github/ISSUE_TEMPLATE/3_tech_support.md b/.github/ISSUE_TEMPLATE/3_tech_support.md new file mode 100644 index 00000000..82afe7a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_tech_support.md @@ -0,0 +1,21 @@ +--- +name: 🆘 I need help with ... +about: Installing ntfy, configuring the app, etc. +title: '' +labels: 'tech-support' +assignees: '' + +--- + + + diff --git a/.github/ISSUE_TEMPLATE/4_question.md b/.github/ISSUE_TEMPLATE/4_question.md new file mode 100644 index 00000000..9d930ef0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4_question.md @@ -0,0 +1,21 @@ +--- +name: ❓ Question +about: Ask a question about ntfy +title: '' +labels: 'question' +assignees: '' + +--- + + + +:question: **Question** + diff --git a/.github/images/logo.png b/.github/images/logo.png new file mode 100644 index 00000000..351db4d7 Binary files /dev/null and b/.github/images/logo.png differ diff --git a/web/public/static/img/screenshot-curl.png b/.github/images/screenshot-curl.png similarity index 100% rename from web/public/static/img/screenshot-curl.png rename to .github/images/screenshot-curl.png diff --git a/web/public/static/img/screenshot-phone-detail.jpg b/.github/images/screenshot-phone-detail.jpg similarity index 100% rename from web/public/static/img/screenshot-phone-detail.jpg rename to .github/images/screenshot-phone-detail.jpg diff --git a/web/public/static/img/screenshot-phone-main.jpg b/.github/images/screenshot-phone-main.jpg similarity index 100% rename from web/public/static/img/screenshot-phone-main.jpg rename to .github/images/screenshot-phone-main.jpg diff --git a/web/public/static/img/screenshot-phone-notification.jpg b/.github/images/screenshot-phone-notification.jpg similarity index 100% rename from web/public/static/img/screenshot-phone-notification.jpg rename to .github/images/screenshot-phone-notification.jpg diff --git a/web/public/static/img/screenshot-web-detail.png b/.github/images/screenshot-web-detail.png similarity index 100% rename from web/public/static/img/screenshot-web-detail.png rename to .github/images/screenshot-web-detail.png diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000..de22292a --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,30 @@ +name: build +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build all the things + run: make build + - + name: Print build results and checksums + run: make cli-build-results diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..6991dea6 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,36 @@ +name: docs +on: + push: + branches: + - main +jobs: + publish-docs: + runs-on: ubuntu-latest + steps: + - + name: Checkout ntfy code + uses: actions/checkout@v3 + - + name: Checkout docs pages code + uses: actions/checkout@v3 + with: + repository: binwiederhier/ntfy-docs.github.io + path: build/ntfy-docs.github.io + token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}} + # Expires after 1 year, re-generate via + # User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token + - + name: Build docs + run: make docs + - + name: Copy generated docs + run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/ + - + name: Publish docs + run: | + cd build/ntfy-docs.github.io + git config user.name "GitHub Actions Bot" + git config user.email "" + git add docs/ + git commit -m "Updated docs" + git push origin main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b61e3361 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,41 @@ +name: release +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' +jobs: + release: + runs-on: ubuntu-latest + steps: + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Docker login + uses: docker/login-action@v2 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build and publish + run: make release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - + name: Print build results and checksums + run: make cli-build-results diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0b63387f..f76862a9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,25 +4,36 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Install Go - uses: actions/setup-go@v2 + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 with: - go-version: '1.17.x' - - name: Install node - uses: actions/setup-node@v2 + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 with: - node-version: '16' - - name: Checkout code - uses: actions/checkout@v2 - - name: Install dependencies - run: sudo apt update && sudo apt install -y python3-pip curl - - name: Build docs (required for tests) + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build docs (required for tests) run: make docs - - name: Build web app (required for tests) + - + name: Build web app (required for tests) run: make web - - name: Run tests, formatting, vetting and linting + - + name: Run tests, formatting, vetting and linting run: make check - - name: Run coverage + - + name: Run coverage run: make coverage - - name: Upload coverage to codecov.io + - + name: Upload coverage to codecov.io run: make coverage-upload diff --git a/.gitignore b/.gitignore index 9f514857..b60c9b23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ dist/ +dev-dist/ build/ .idea/ +.vscode/ +*.swp server/docs/ server/site/ tools/fbsend/fbsend playground/ +secrets/ *.iml node_modules/ +.DS_Store +__pycache__ +web/dev-dist/ \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..6cccd8f2 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,28 @@ +tasks: + - name: docs + before: make docs-deps + command: mkdocs serve + - name: binary + before: | + npm install --global nodemon + make cli-deps-static-sites + command: | + nodemon --watch './**/*.go' --ext go --signal SIGTERM --exec "CGO_ENABLED=1 go run main.go serve --listen-http :2586 --debug --base-url $(gp url 2586)" + openMode: split-right + - name: web + before: make web-deps + command: cd web && npm start + openMode: split-right + +vscode: + extensions: + - golang.go + - ms-azuretools.vscode-docker + +ports: + - name: docs + port: 8000 + - name: binary + port: 2586 + - name: web + port: 3000 \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index e3c75049..3c3aa490 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,7 @@ before: - go mod tidy builds: - - id: ntfy_amd64 + id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 @@ -13,11 +13,8 @@ builds: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" goos: [linux] goarch: [amd64] - hooks: - post: - - upx "{{ .Path }}" # apt install upx - - id: ntfy_armv6 + id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 @@ -28,10 +25,8 @@ builds: goos: [linux] goarch: [arm] goarm: [6] - # No "upx", since it causes random core dumps, see - # https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 - - id: ntfy_armv7 + id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 @@ -42,10 +37,8 @@ builds: goos: [linux] goarch: [arm] goarm: [7] - # No "upx", since it causes random core dumps, see - # https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 - - id: ntfy_arm64 + id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 @@ -55,12 +48,30 @@ builds: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" goos: [linux] goarch: [arm64] - # No "upx", since it causes random core dumps, see - # https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 + - + id: ntfy_windows_amd64 + binary: ntfy + env: + - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 + tags: [noserver] # don't include server files + ldflags: + - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [windows] + goarch: [amd64] + - + id: ntfy_darwin_all + binary: ntfy + env: + - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 + tags: [noserver] # don't include server files + ldflags: + - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [darwin] + goarch: [amd64, arm64] # will be combined to "universal binary" (see below) nfpms: - package_name: ntfy - homepage: https://heckel.io/ntfy + homepage: https://git.zio.sh/astra/ntfy/v2 maintainer: Philipp C. Heckel description: Simple pub-sub notification service license: Apache 2.0 @@ -86,7 +97,7 @@ nfpms: - dst: /var/lib/ntfy type: dir - dst: /usr/share/ntfy/logo.png - src: web/public/static/img/ntfy.png + src: web/public/static/images/ntfy.png scripts: preinstall: "scripts/preinst.sh" postinstall: "scripts/postinst.sh" @@ -94,6 +105,12 @@ nfpms: postremove: "scripts/postrm.sh" archives: - + id: ntfy_linux + builds: + - ntfy_linux_amd64 + - ntfy_linux_armv6 + - ntfy_linux_armv7 + - ntfy_linux_arm64 wrap_in_directory: true files: - LICENSE @@ -102,9 +119,30 @@ archives: - server/ntfy.service - client/client.yml - client/ntfy-client.service - replacements: - 386: i386 - amd64: x86_64 + - + id: ntfy_windows + builds: + - ntfy_windows_amd64 + format: zip + wrap_in_directory: true + files: + - LICENSE + - README.md + - client/client.yml + - + id: ntfy_darwin + builds: + - ntfy_darwin_all + wrap_in_directory: true + files: + - LICENSE + - README.md + - client/client.yml +universal_binaries: + - + id: ntfy_darwin_all + replace: true + name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: @@ -126,14 +164,14 @@ dockers: - image_templates: - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" use: buildx - dockerfile: Dockerfile + dockerfile: Dockerfile-arm goarch: arm64 build_flag_templates: - "--platform=linux/arm64/v8" - image_templates: - &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7" use: buildx - dockerfile: Dockerfile + dockerfile: Dockerfile-arm goarch: arm goarm: 7 build_flag_templates: @@ -141,7 +179,7 @@ dockers: - image_templates: - &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6" use: buildx - dockerfile: Dockerfile + dockerfile: Dockerfile-arm goarch: arm goarm: 6 build_flag_templates: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..863c0996 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier), +or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/Dockerfile b/Dockerfile index 6916cabc..45dad05d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,15 @@ FROM alpine -MAINTAINER Philipp C. Heckel +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +RUN apk add --no-cache tzdata COPY ntfy /usr/bin EXPOSE 80/tcp diff --git a/Dockerfile-arm b/Dockerfile-arm new file mode 100644 index 00000000..755092fd --- /dev/null +++ b/Dockerfile-arm @@ -0,0 +1,18 @@ +FROM alpine + +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +# Alpine does not support adding "tzdata" on ARM anymore, see +# https://github.com/binwiederhier/ntfy/issues/894 + +COPY ntfy /usr/bin + +EXPOSE 80/tcp +ENTRYPOINT ["ntfy"] diff --git a/Dockerfile-build b/Dockerfile-build new file mode 100644 index 00000000..6e96c7d4 --- /dev/null +++ b/Dockerfile-build @@ -0,0 +1,57 @@ +FROM golang:1.20-bullseye as builder + +ARG VERSION=dev +ARG COMMIT=unknown +ARG NODE_MAJOR=18 + +RUN apt-get update && apt-get install -y \ + build-essential ca-certificates curl gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y \ + python3-pip nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ADD Makefile . + +# docs +ADD ./requirements.txt . +RUN make docs-deps +ADD ./mkdocs.yml . +ADD ./docs ./docs +RUN make docs-build + +# web +ADD ./web/package.json ./web/package-lock.json ./web/ +RUN make web-deps +ADD ./web ./web +RUN make web-build + +# cli & server +ADD go.mod go.sum main.go ./ +ADD ./client ./client +ADD ./cmd ./cmd +ADD ./log ./log +ADD ./server ./server +ADD ./user ./user +ADD ./util ./util +RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server + +FROM alpine + +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy + +EXPOSE 80/tcp +ENTRYPOINT ["ntfy"] diff --git a/Makefile b/Makefile index 8a5d9c32..88c22033 100644 --- a/Makefile +++ b/Makefile @@ -1,64 +1,79 @@ +MAKEFLAGS := --jobs=1 VERSION := $(shell git describe --tag) +COMMIT := $(shell git rev-parse --short HEAD) .PHONY: help: @echo "Typical commands (more see below):" - @echo " make build - Build web app, documentation and server/client (sloowwww)" - @echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)" - @echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)" - @echo " make web - Build the web app" - @echo " make docs - Build the documentation" - @echo " make check - Run all tests, vetting/formatting checks and linters" + @echo " make build - Build web app, documentation and server/client (sloowwww)" + @echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)" + @echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)" + @echo " make web - Build the web app" + @echo " make docs - Build the documentation" + @echo " make check - Run all tests, vetting/formatting checks and linters" @echo @echo "Build everything:" - @echo " make build - Build web app, documentation and server/client" - @echo " make clean - Clean build/dist folders" + @echo " make build - Build web app, documentation and server/client" + @echo " make clean - Clean build/dist folders" @echo - @echo "Build server & client (not release version):" - @echo " make server - Build server & client (all architectures)" - @echo " make server-amd64 - Build server & client (amd64 only)" - @echo " make server-armv6 - Build server & client (armv6 only)" - @echo " make server-armv7 - Build server & client (armv7 only)" - @echo " make server-arm64 - Build server & client (arm64 only)" + @echo "Build server & client (using GoReleaser, not release version):" + @echo " make cli - Build server & client (all architectures)" + @echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)" + @echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)" + @echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)" + @echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)" + @echo " make cli-windows-amd64 - Build client (Windows, amd64 only)" + @echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)" + @echo + @echo "Build server & client (without GoReleaser):" + @echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)" + @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" + @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" + @echo + @echo "Build dev Docker:" + @echo " make docker-dev - Build client & server for current architecture using Docker only" @echo @echo "Build web app:" - @echo " make web - Build the web app" - @echo " make web-deps - Install web app dependencies (npm install the universe)" - @echo " make web-build - Actually build the web app" + @echo " make web - Build the web app" + @echo " make web-deps - Install web app dependencies (npm install the universe)" + @echo " make web-build - Actually build the web app" + @echo " make web-lint - Run eslint on the web app" + @echo " make web-fmt - Run prettier on the web app" + @echo " make web-fmt-check - Run prettier on the web app, but don't change anything" @echo @echo "Build documentation:" - @echo " make docs - Build the documentation" - @echo " make docs-deps - Install Python dependencies (pip3 install)" - @echo " make docs-build - Actually build the documentation" + @echo " make docs - Build the documentation" + @echo " make docs-deps - Install Python dependencies (pip3 install)" + @echo " make docs-build - Actually build the documentation" @echo @echo "Test/check:" - @echo " make test - Run tests" - @echo " make race - Run tests with -race flag" - @echo " make coverage - Run tests and show coverage" - @echo " make coverage-html - Run tests and show coverage (as HTML)" - @echo " make coverage-upload - Upload coverage results to codecov.io" + @echo " make test - Run tests" + @echo " make race - Run tests with -race flag" + @echo " make coverage - Run tests and show coverage" + @echo " make coverage-html - Run tests and show coverage (as HTML)" + @echo " make coverage-upload - Upload coverage results to codecov.io" @echo @echo "Lint/format:" - @echo " make fmt - Run 'go fmt'" - @echo " make fmt-check - Run 'go fmt', but don't change anything" - @echo " make vet - Run 'go vet'" - @echo " make lint - Run 'golint'" - @echo " make staticcheck - Run 'staticcheck'" + @echo " make fmt - Run 'go fmt'" + @echo " make fmt-check - Run 'go fmt', but don't change anything" + @echo " make vet - Run 'go vet'" + @echo " make lint - Run 'golint'" + @echo " make staticcheck - Run 'staticcheck'" @echo @echo "Releasing:" - @echo " make release - Create a release" - @echo " make release-snapshot - Create a test release" + @echo " make release - Create a release" + @echo " make release-snapshot - Create a test release" @echo @echo "Install locally (requires sudo):" - @echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy" - @echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy" - @echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy" - @echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy" - @echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)" - @echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)" - @echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)" - @echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)" + @echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)" + @echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)" + @echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)" + @echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)" # Building everything @@ -66,28 +81,49 @@ help: clean: .PHONY rm -rf dist build server/docs server/site -build: web docs server +build: web docs cli +update: web-deps-update cli-deps-update docs-deps-update + docker pull alpine + +docker-dev: + docker build \ + --file ./Dockerfile-build \ + --tag binwiederhier/ntfy:$(VERSION) \ + --tag binwiederhier/ntfy:dev \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + ./ + +# Ubuntu-specific + +build-deps-ubuntu: + sudo apt-get update + sudo apt-get install -y \ + curl \ + gcc-aarch64-linux-gnu \ + gcc-arm-linux-gnueabi \ + jq + which pip3 || sudo apt-get install -y python3-pip # Documentation docs: docs-deps docs-build +docs-build: .PHONY + mkdocs build + docs-deps: .PHONY pip3 install -r requirements.txt -docs-build: .PHONY - mkdocs build +docs-deps-update: .PHONY + pip3 install -r requirements.txt --upgrade # Web app web: web-deps web-build -web-deps: - cd web && npm install - # If this fails for .svg files, optimizes them with svgo - web-build: cd web \ && npm run build \ @@ -95,58 +131,126 @@ web-build: && rm -rf ../server/site \ && mv build ../server/site \ && rm \ - ../server/site/config.js \ - ../server/site/asset-manifest.json + ../server/site/config.js +web-deps: + cd web && npm install + # If this fails for .svg files, optimize them with svgo + +web-deps-update: + cd web && npm update + +web-fmt: + cd web && npm run format + +web-fmt-check: + cd web && npm run format:check + +web-lint: + cd web && npm run lint # Main server/client build -server: server-deps - goreleaser build --snapshot --rm-dist --debug +cli: cli-deps + goreleaser build --snapshot --clean -server-amd64: server-deps-static-sites - goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64 +cli-linux-amd64: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_linux_amd64 -server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7 - goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6 +cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 + goreleaser build --snapshot --clean --id ntfy_linux_armv6 -server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7 - goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7 +cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 + goreleaser build --snapshot --clean --id ntfy_linux_armv7 -server-arm64: server-deps-static-sites server-deps-gcc-arm64 - goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64 +cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 + goreleaser build --snapshot --clean --id ntfy_linux_arm64 -server-deps: server-deps-static-sites server-deps-all server-deps-gcc +cli-windows-amd64: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_windows_amd64 -server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64 +cli-darwin-all: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_darwin_all -server-deps-static-sites: +cli-linux-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) manually. + # Use this for development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_linux_server server/docs + CGO_ENABLED=1 go build \ + -o dist/ntfy_linux_server/ntfy \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + +cli-darwin-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) manually. + # Use this for macOS/iOS development, so you have a local server to test with. + mkdir -p dist/ntfy_darwin_server server/docs + CGO_ENABLED=1 go build \ + -o dist/ntfy_darwin_server/ntfy \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + +cli-client: cli-deps-static-sites + # This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows. + # Use this for development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_client server/docs + CGO_ENABLED=0 go build \ + -o dist/ntfy_client/ntfy \ + -tags noserver \ + -ldflags \ + "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + +cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc + +cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 + +cli-deps-static-sites: mkdir -p server/docs server/site touch server/docs/index.html server/site/app.html -server-deps-all: - which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; } +cli-deps-all: + go install github.com/goreleaser/goreleaser@latest -server-deps-gcc-armv6-armv7: +cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } -server-deps-gcc-arm64: +cli-deps-gcc-arm64: which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } +cli-deps-update: + go get -u + go install honnef.co/go/tools/cmd/staticcheck@latest + go install golang.org/x/lint/golint@latest + go install github.com/goreleaser/goreleaser@latest + +cli-build-results: + cat dist/config.yaml + [ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true + [ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true + [ -f dist/checksums.txt ] && cat dist/checksums.txt || true + find dist -maxdepth 2 -type f \ + \( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \ + -and -not -path 'dist/goreleaserdocker*' \ + -exec sha256sum {} \; # Test/check targets -check: test fmt-check vet lint staticcheck +check: test web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY + go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + +testv: .PHONY go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') race: .PHONY - go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') coverage: mkdir -p build/coverage - go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go tool cover -func build/coverage/coverage.txt coverage-html: @@ -160,7 +264,7 @@ coverage-upload: # Lint/formatting targets -fmt: +fmt: web-fmt gofmt -s -w . fmt-check: @@ -184,13 +288,13 @@ staticcheck: .PHONY # Releasing targets -release: clean server-deps release-check-tags docs web check - goreleaser release --rm-dist --debug +release: clean cli-deps release-checks docs web check + goreleaser release --clean -release-snapshot: clean server-deps docs web check - goreleaser release --snapshot --skip-publish --rm-dist --debug +release-snapshot: clean cli-deps docs web check + goreleaser release --snapshot --skip-publish --clean -release-check-tags: +release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) if ! grep -q $(LATEST_TAG) docs/install.md; then\ echo "ERROR: Must update docs/install.md with latest tag first.";\ @@ -200,35 +304,39 @@ release-check-tags: echo "ERROR: Must update docs/releases.md with latest tag first.";\ exit 1;\ fi + if [ -n "$(shell git status -s)" ]; then\ + echo "ERROR: Git repository is in an unclean state.";\ + exit 1;\ + fi # Installing targets -install-amd64: remove-binary - sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy +install-linux-amd64: remove-binary + sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy -install-armv6: remove-binary - sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy +install-linux-armv6: remove-binary + sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy -install-armv7: remove-binary - sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy +install-linux-armv7: remove-binary + sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy -install-arm64: remove-binary - sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy +install-linux-arm64: remove-binary + sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy remove-binary: sudo rm -f /usr/bin/ntfy -install-amd64-deb: purge-package +install-linux-amd64-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_amd64.deb -install-armv6-deb: purge-package +install-linux-armv6-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_armv6.deb -install-armv7-deb: purge-package +install-linux-armv7-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_armv7.deb -install-arm64-deb: purge-package +install-linux-arm64-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_arm64.deb purge-package: diff --git a/README.md b/README.md index 94ac1d4f..b3d0c55e 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,9 @@ -![ntfy](web/public/static/img/ntfy.png) - # ntfy.sh | Send push notifications to your phone or desktop via PUT/POST -[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest) -[![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy) -[![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions) -[![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy) -[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy) -[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w) -[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org) -[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/) -**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. -It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**. -It's also open source (as you can plainly see) if you want to run your own. +**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) +notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, +**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do +so since ntfy is open source. -I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [open source](https://github.com/binwiederhier/ntfy-android) [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy) -too. -

- - - - - -

- -## **[Documentation](https://ntfy.sh/docs/)** - -[Getting started](https://ntfy.sh/docs/) | -[Android/iOS](https://ntfy.sh/docs/subscribe/phone/) | -[API](https://ntfy.sh/docs/publish/) | -[Install / Self-hosting](https://ntfy.sh/docs/install/) | -[Building](https://ntfy.sh/docs/develop/) - -## Contributing -I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out -the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app. -Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in -[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/). - - -Translation status - - -## Contact me -You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org) -(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information -[on my website](https://heckel.io/about). - -## License -Made with ❤️ by [Philipp C. Heckel](https://heckel.io). -The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). - -Third party libraries and resources: -* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI -* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds -* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds -* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web -* [React](https://reactjs.org/) (MIT) is used for the web app -* [Material UI components](https://mui.com/) (MIT) are used in the web app -* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app -* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB -* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases -* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails -* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests -* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache -* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages -* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file) -* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page -* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files -* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used) -* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) -* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs) +### This is a fork of [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..45573756 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security Policy + +## Supported Versions + +As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date. + +## Reporting a Vulnerability + +Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), +or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). diff --git a/auth/auth.go b/auth/auth.go deleted file mode 100644 index 35b910c5..00000000 --- a/auth/auth.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package auth deals with authentication and authorization against topics -package auth - -import ( - "errors" - "regexp" -) - -// Auther is a generic interface to implement password-based authentication and authorization -type Auther interface { - // Authenticate checks username and password and returns a user if correct. The method - // returns in constant-ish time, regardless of whether the user exists or the password is - // correct or incorrect. - Authenticate(username, password string) (*User, error) - - // Authorize returns nil if the given user has access to the given topic using the desired - // permission. The user param may be nil to signal an anonymous user. - Authorize(user *User, topic string, perm Permission) error -} - -// Manager is an interface representing user and access management -type Manager interface { - // AddUser adds a user with the given username, password and role. The password should be hashed - // before it is stored in a persistence layer. - AddUser(username, password string, role Role) error - - // RemoveUser deletes the user with the given username. The function returns nil on success, even - // if the user did not exist in the first place. - RemoveUser(username string) error - - // Users returns a list of users. It always also returns the Everyone user ("*"). - Users() ([]*User, error) - - // User returns the user with the given username if it exists, or ErrNotFound otherwise. - // You may also pass Everyone to retrieve the anonymous user and its Grant list. - User(username string) (*User, error) - - // ChangePassword changes a user's password - ChangePassword(username, password string) error - - // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, - // all existing access control entries (Grant) are removed, since they are no longer needed. - ChangeRole(username string, role Role) error - - // AllowAccess adds or updates an entry in th access control list for a specific user. It controls - // read/write access to a topic. The parameter topicPattern may include wildcards (*). - AllowAccess(username string, topicPattern string, read bool, write bool) error - - // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is - // empty) for an entire user. The parameter topicPattern may include wildcards (*). - ResetAccess(username string, topicPattern string) error - - // DefaultAccess returns the default read/write access if no access control entry matches - DefaultAccess() (read bool, write bool) -} - -// User is a struct that represents a user -type User struct { - Name string - Hash string // password hash (bcrypt) - Role Role - Grants []Grant -} - -// Grant is a struct that represents an access control entry to a topic -type Grant struct { - TopicPattern string // May include wildcard (*) - AllowRead bool - AllowWrite bool -} - -// Permission represents a read or write permission to a topic -type Permission int - -// Permissions to a topic -const ( - PermissionRead = Permission(1) - PermissionWrite = Permission(2) -) - -// Role represents a user's role, either admin or regular user -type Role string - -// User roles -const ( - RoleAdmin = Role("admin") - RoleUser = Role("user") - RoleAnonymous = Role("anonymous") -) - -// Everyone is a special username representing anonymous users -const ( - Everyone = "*" -) - -var ( - allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) - allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! -) - -// AllowedRole returns true if the given role can be used for new users -func AllowedRole(role Role) bool { - return role == RoleUser || role == RoleAdmin -} - -// AllowedUsername returns true if the given username is valid -func AllowedUsername(username string) bool { - return allowedUsernameRegex.MatchString(username) -} - -// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) -func AllowedTopicPattern(username string) bool { - return allowedTopicPatternRegex.MatchString(username) -} - -// Error constants used by the package -var ( - ErrUnauthenticated = errors.New("unauthenticated") - ErrUnauthorized = errors.New("unauthorized") - ErrInvalidArgument = errors.New("invalid argument") - ErrNotFound = errors.New("not found") -) diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go deleted file mode 100644 index f2bad460..00000000 --- a/auth/auth_sqlite.go +++ /dev/null @@ -1,399 +0,0 @@ -package auth - -import ( - "database/sql" - "errors" - "fmt" - _ "github.com/mattn/go-sqlite3" // SQLite driver - "golang.org/x/crypto/bcrypt" - "strings" -) - -const ( - bcryptCost = 10 - intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost -) - -// Auther-related queries -const ( - createAuthTablesQueries = ` - BEGIN; - CREATE TABLE IF NOT EXISTS user ( - user TEXT NOT NULL PRIMARY KEY, - pass TEXT NOT NULL, - role TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS access ( - user TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - PRIMARY KEY (topic, user) - ); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - COMMIT; - ` - selectUserQuery = `SELECT pass, role FROM user WHERE user = ?` - selectTopicPermsQuery = ` - SELECT read, write - FROM access - WHERE user IN ('*', ?) AND ? LIKE topic - ORDER BY user DESC - ` -) - -// Manager-related queries -const ( - insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` - selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user` - updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` - updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` - deleteUserQuery = `DELETE FROM user WHERE user = ?` - - upsertUserAccessQuery = ` - INSERT INTO access (user, topic, read, write) - VALUES (?, ?, ?, ?) - ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write - ` - selectUserAccessQuery = `SELECT topic, read, write FROM access WHERE user = ?` - deleteAllAccessQuery = `DELETE FROM access` - deleteUserAccessQuery = `DELETE FROM access WHERE user = ?` - deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?` -) - -// Schema management queries -const ( - currentSchemaVersion = 1 - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` -) - -// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list -// in a SQLite database. -type SQLiteAuth struct { - db *sql.DB - defaultRead bool - defaultWrite bool -} - -var _ Auther = (*SQLiteAuth)(nil) -var _ Manager = (*SQLiteAuth)(nil) - -// NewSQLiteAuth creates a new SQLiteAuth instance -func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) { - db, err := sql.Open("sqlite3", filename) - if err != nil { - return nil, err - } - if err := setupAuthDB(db); err != nil { - return nil, err - } - return &SQLiteAuth{ - db: db, - defaultRead: defaultRead, - defaultWrite: defaultWrite, - }, nil -} - -// Authenticate checks username and password and returns a user if correct. The method -// returns in constant-ish time, regardless of whether the user exists or the password is -// correct or incorrect. -func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { - if username == Everyone { - return nil, ErrUnauthenticated - } - user, err := a.User(username) - if err != nil { - bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), - []byte("intentional slow-down to avoid timing attacks")) - return nil, ErrUnauthenticated - } - if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { - return nil, ErrUnauthenticated - } - return user, nil -} - -// Authorize returns nil if the given user has access to the given topic using the desired -// permission. The user param may be nil to signal an anonymous user. -func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { - if user != nil && user.Role == RoleAdmin { - return nil // Admin can do everything - } - username := Everyone - if user != nil { - username = user.Name - } - // Select the read/write permissions for this user/topic combo. The query may return two - // rows (one for everyone, and one for the user), but prioritizes the user. The value for - // user.Name may be empty (= everyone). - rows, err := a.db.Query(selectTopicPermsQuery, username, topic) - if err != nil { - return err - } - defer rows.Close() - if !rows.Next() { - return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) - } - var read, write bool - if err := rows.Scan(&read, &write); err != nil { - return err - } else if err := rows.Err(); err != nil { - return err - } - return a.resolvePerms(read, write, perm) -} - -func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error { - if perm == PermissionRead && read { - return nil - } else if perm == PermissionWrite && write { - return nil - } - return ErrUnauthorized -} - -// AddUser adds a user with the given username, password and role. The password should be hashed -// before it is stored in a persistence layer. -func (a *SQLiteAuth) AddUser(username, password string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - return err - } - if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { - return err - } - return nil -} - -// RemoveUser deletes the user with the given username. The function returns nil on success, even -// if the user did not exist in the first place. -func (a *SQLiteAuth) RemoveUser(username string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(deleteUserQuery, username); err != nil { - return err - } - if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { - return err - } - return nil -} - -// Users returns a list of users. It always also returns the Everyone user ("*"). -func (a *SQLiteAuth) Users() ([]*User, error) { - rows, err := a.db.Query(selectUsernamesQuery) - if err != nil { - return nil, err - } - defer rows.Close() - usernames := make([]string, 0) - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - usernames = append(usernames, username) - } - rows.Close() - users := make([]*User, 0) - for _, username := range usernames { - user, err := a.User(username) - if err != nil { - return nil, err - } - users = append(users, user) - } - everyone, err := a.everyoneUser() - if err != nil { - return nil, err - } - users = append(users, everyone) - return users, nil -} - -// User returns the user with the given username if it exists, or ErrNotFound otherwise. -// You may also pass Everyone to retrieve the anonymous user and its Grant list. -func (a *SQLiteAuth) User(username string) (*User, error) { - if username == Everyone { - return a.everyoneUser() - } - rows, err := a.db.Query(selectUserQuery, username) - if err != nil { - return nil, err - } - defer rows.Close() - var hash, role string - if !rows.Next() { - return nil, ErrNotFound - } - if err := rows.Scan(&hash, &role); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants, err := a.readGrants(username) - if err != nil { - return nil, err - } - return &User{ - Name: username, - Hash: hash, - Role: Role(role), - Grants: grants, - }, nil -} - -func (a *SQLiteAuth) everyoneUser() (*User, error) { - grants, err := a.readGrants(Everyone) - if err != nil { - return nil, err - } - return &User{ - Name: Everyone, - Hash: "", - Role: RoleAnonymous, - Grants: grants, - }, nil -} - -func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) { - rows, err := a.db.Query(selectUserAccessQuery, username) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make([]Grant, 0) - for rows.Next() { - var topic string - var read, write bool - if err := rows.Scan(&topic, &read, &write); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants = append(grants, Grant{ - TopicPattern: fromSQLWildcard(topic), - AllowRead: read, - AllowWrite: write, - }) - } - return grants, nil -} - -// ChangePassword changes a user's password -func (a *SQLiteAuth) ChangePassword(username, password string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - return err - } - if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { - return err - } - return nil -} - -// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, -// all existing access control entries (Grant) are removed, since they are no longer needed. -func (a *SQLiteAuth) ChangeRole(username string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { - return err - } - if role == RoleAdmin { - if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { - return err - } - } - return nil -} - -// AllowAccess adds or updates an entry in th access control list for a specific user. It controls -// read/write access to a topic. The parameter topicPattern may include wildcards (*). -func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error { - if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { - return ErrInvalidArgument - } - if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { - return err - } - return nil -} - -// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is -// empty) for an entire user. The parameter topicPattern may include wildcards (*). -func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { - return ErrInvalidArgument - } - if username == "" && topicPattern == "" { - _, err := a.db.Exec(deleteAllAccessQuery, username) - return err - } else if topicPattern == "" { - _, err := a.db.Exec(deleteUserAccessQuery, username) - return err - } - _, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) - return err -} - -// DefaultAccess returns the default read/write access if no access control entry matches -func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) { - return a.defaultRead, a.defaultWrite -} - -func toSQLWildcard(s string) string { - return strings.ReplaceAll(s, "*", "%") -} - -func fromSQLWildcard(s string) string { - return strings.ReplaceAll(s, "%", "*") -} - -func setupAuthDB(db *sql.DB) error { - // If 'schemaVersion' table does not exist, this must be a new database - rowsSV, err := db.Query(selectSchemaVersionQuery) - if err != nil { - return setupNewAuthDB(db) - } - defer rowsSV.Close() - - // If 'schemaVersion' table exists, read version and potentially upgrade - schemaVersion := 0 - if !rowsSV.Next() { - return errors.New("cannot determine schema version: database file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - - // Do migrations - if schemaVersion == currentSchemaVersion { - return nil - } - return fmt.Errorf("unexpected schema version found: %d", schemaVersion) -} - -func setupNewAuthDB(db *sql.DB) error { - if _, err := db.Exec(createAuthTablesQueries); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil -} diff --git a/auth/auth_sqlite_test.go b/auth/auth_sqlite_test.go deleted file mode 100644 index 4c1e817c..00000000 --- a/auth/auth_sqlite_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package auth_test - -import ( - "github.com/stretchr/testify/require" - "heckel.io/ntfy/auth" - "path/filepath" - "strings" - "testing" - "time" -) - -const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources - -func TestSQLiteAuth_FullScenario_Default_DenyAll(t *testing.T) { - a := newTestAuth(t, false, false) - require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) - require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! - require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false)) - require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true)) - require.Nil(t, a.AllowAccess(auth.Everyone, "up*", false, true)) // Everyone can write to /up* - - phil, err := a.Authenticate("phil", "phil") - require.Nil(t, err) - require.Equal(t, "phil", phil.Name) - require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) - require.Equal(t, auth.RoleAdmin, phil.Role) - require.Equal(t, []auth.Grant{}, phil.Grants) - - ben, err := a.Authenticate("ben", "ben") - require.Nil(t, err) - require.Equal(t, "ben", ben.Name) - require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) - require.Equal(t, auth.RoleUser, ben.Role) - require.Equal(t, []auth.Grant{ - {"mytopic", true, true}, - {"readme", true, false}, - {"writeme", false, true}, - {"everyonewrite", false, false}, - }, ben.Grants) - - notben, err := a.Authenticate("ben", "this is wrong") - require.Nil(t, notben) - require.Equal(t, auth.ErrUnauthenticated, err) - - // Admin can do everything - require.Nil(t, a.Authorize(phil, "sometopic", auth.PermissionWrite)) - require.Nil(t, a.Authorize(phil, "mytopic", auth.PermissionRead)) - require.Nil(t, a.Authorize(phil, "readme", auth.PermissionWrite)) - require.Nil(t, a.Authorize(phil, "writeme", auth.PermissionWrite)) - require.Nil(t, a.Authorize(phil, "announcements", auth.PermissionWrite)) - require.Nil(t, a.Authorize(phil, "everyonewrite", auth.PermissionWrite)) - - // User cannot do everything - require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite)) - require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead)) - require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "writeme", auth.PermissionRead)) - require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) - require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionWrite)) - require.Nil(t, a.Authorize(ben, "announcements", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "announcements", auth.PermissionWrite)) - - // Everyone else can do barely anything - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionRead)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionWrite)) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "announcements", auth.PermissionWrite)) - require.Nil(t, a.Authorize(nil, "announcements", auth.PermissionRead)) - require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionRead)) - require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionWrite)) - require.Nil(t, a.Authorize(nil, "up1234", auth.PermissionWrite)) // Wildcard permission - require.Nil(t, a.Authorize(nil, "up5678", auth.PermissionWrite)) -} - -func TestSQLiteAuth_AddUser_Invalid(t *testing.T) { - a := newTestAuth(t, false, false) - require.Equal(t, auth.ErrInvalidArgument, a.AddUser(" invalid ", "pass", auth.RoleAdmin)) - require.Equal(t, auth.ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role")) -} - -func TestSQLiteAuth_AddUser_Timing(t *testing.T) { - a := newTestAuth(t, false, false) - start := time.Now().UnixMilli() - require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin)) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) -} - -func TestSQLiteAuth_Authenticate_Timing(t *testing.T) { - a := newTestAuth(t, false, false) - require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin)) - - // Timing a correct attempt - start := time.Now().UnixMilli() - _, err := a.Authenticate("user", "pass") - require.Nil(t, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) - - // Timing an incorrect attempt - start = time.Now().UnixMilli() - _, err = a.Authenticate("user", "INCORRECT") - require.Equal(t, auth.ErrUnauthenticated, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) - - // Timing a non-existing user attempt - start = time.Now().UnixMilli() - _, err = a.Authenticate("DOES-NOT-EXIST", "hithere") - require.Equal(t, auth.ErrUnauthenticated, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) -} - -func TestSQLiteAuth_UserManagement(t *testing.T) { - a := newTestAuth(t, false, false) - require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) - require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! - require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false)) - require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true)) - - // Query user details - phil, err := a.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", phil.Name) - require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) - require.Equal(t, auth.RoleAdmin, phil.Role) - require.Equal(t, []auth.Grant{}, phil.Grants) - - ben, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, "ben", ben.Name) - require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) - require.Equal(t, auth.RoleUser, ben.Role) - require.Equal(t, []auth.Grant{ - {"mytopic", true, true}, - {"readme", true, false}, - {"writeme", false, true}, - {"everyonewrite", false, false}, - }, ben.Grants) - - everyone, err := a.User(auth.Everyone) - require.Nil(t, err) - require.Equal(t, "*", everyone.Name) - require.Equal(t, "", everyone.Hash) - require.Equal(t, auth.RoleAnonymous, everyone.Role) - require.Equal(t, []auth.Grant{ - {"announcements", true, false}, - {"everyonewrite", true, true}, - }, everyone.Grants) - - // Ben: Before revoking - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) - require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead)) - require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite)) - require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) - require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) - - // Revoke access for "ben" to "mytopic", then check again - require.Nil(t, a.ResetAccess("ben", "mytopic")) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionWrite)) // Revoked - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionRead)) // Revoked - require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) // Unchanged - require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) // Unchanged - - // Revoke rest of the access - require.Nil(t, a.ResetAccess("ben", "")) - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionRead)) // Revoked - require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "wrtiteme", auth.PermissionWrite)) // Revoked - - // User list - users, err := a.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, "*", users[2].Name) - - // Remove user - require.Nil(t, a.RemoveUser("ben")) - _, err = a.User("ben") - require.Equal(t, auth.ErrNotFound, err) - - users, err = a.Users() - require.Nil(t, err) - require.Equal(t, 2, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "*", users[1].Name) -} - -func TestSQLiteAuth_ChangePassword(t *testing.T) { - a := newTestAuth(t, false, false) - require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) - - _, err := a.Authenticate("phil", "phil") - require.Nil(t, err) - - require.Nil(t, a.ChangePassword("phil", "newpass")) - _, err = a.Authenticate("phil", "phil") - require.Equal(t, auth.ErrUnauthenticated, err) - _, err = a.Authenticate("phil", "newpass") - require.Nil(t, err) -} - -func TestSQLiteAuth_ChangeRole(t *testing.T) { - a := newTestAuth(t, false, false) - require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - - ben, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, auth.RoleUser, ben.Role) - require.Equal(t, 2, len(ben.Grants)) - - require.Nil(t, a.ChangeRole("ben", auth.RoleAdmin)) - - ben, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, auth.RoleAdmin, ben.Role) - require.Equal(t, 0, len(ben.Grants)) -} - -func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth { - filename := filepath.Join(t.TempDir(), "user.db") - a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite) - require.Nil(t, err) - return a -} diff --git a/client/client.go b/client/client.go index eaff5673..191df260 100644 --- a/client/client.go +++ b/client/client.go @@ -7,27 +7,29 @@ import ( "encoding/json" "errors" "fmt" - "heckel.io/ntfy/util" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" "io" - "log" "net/http" + "regexp" "strings" "sync" "time" ) -// Event type constants const ( - MessageEvent = "message" - KeepaliveEvent = "keepalive" - OpenEvent = "open" - PollRequestEvent = "poll_request" + // MessageEvent identifies a message event + MessageEvent = "message" ) const ( maxResponseBytes = 4096 ) +var ( + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go +) + // Client is the ntfy client that can be used to publish and subscribe to ntfy topics type Client struct { Messages chan *Message @@ -47,6 +49,7 @@ type Message struct { // TODO combine with server.message Priority int Tags []string Click string + Icon string Attachment *Attachment // Additional fields @@ -95,13 +98,20 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // WithNoFirebase, and the generic WithHeader. func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { - topicURL := c.expandTopicURL(topic) - req, _ := http.NewRequest("POST", topicURL, body) + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", topicURL, body) + if err != nil { + return nil, err + } for _, option := range options { if err := option(req); err != nil { return nil, err } } + log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err @@ -131,11 +141,15 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO // By default, all messages will be returned, but you can change this behavior using a SubscribeOption. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } ctx := context.Background() messages := make([]*Message, 0) msgChan := make(chan *Message) errChan := make(chan error) - topicURL := c.expandTopicURL(topic) + log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) options = append(options, WithPoll()) go func() { err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) @@ -161,16 +175,21 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err // The method returns a unique subscriptionID that can be used in Unsubscribe. // // Example: -// c := client.New(client.NewConfig()) -// subscriptionID := c.Subscribe("mytopic") -// for m := range c.Messages { -// fmt.Printf("New message: %s", m.Message) -// } -func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { +// +// c := client.New(client.NewConfig()) +// subscriptionID, _ := c.Subscribe("mytopic") +// for m := range c.Messages { +// fmt.Printf("New message: %s", m.Message) +// } +func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return "", err + } c.mu.Lock() defer c.mu.Unlock() subscriptionID := util.RandomString(10) - topicURL := c.expandTopicURL(topic) + log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) ctx, cancel := context.WithCancel(context.Background()) c.subscriptions[subscriptionID] = &subscription{ ID: subscriptionID, @@ -178,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { cancel: cancel, } go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) - return subscriptionID + return subscriptionID, nil } // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique @@ -194,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) { sub.cancel() } -// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe. -// If there are multiple subscriptions matching the topic, all of them are unsubscribed from. -// -// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// -// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the -// config (e.g. mytopic -> https://ntfy.sh/mytopic). -func (c *Client) UnsubscribeAll(topic string) { - c.mu.Lock() - defer c.mu.Unlock() - topicURL := c.expandTopicURL(topic) - for _, sub := range c.subscriptions { - if sub.topicURL == topicURL { - delete(c.subscriptions, sub.ID) - sub.cancel() - } - } -} - -func (c *Client) expandTopicURL(topic string) string { +func (c *Client) expandTopicURL(topic string) (string, error) { if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { - return topic + return topic, nil } else if strings.Contains(topic, "/") { - return fmt.Sprintf("https://%s", topic) + return fmt.Sprintf("https://%s", topic), nil } - return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) + if !topicRegex.MatchString(topic) { + return "", fmt.Errorf("invalid topic name: %s", topic) + } + return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil } func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { @@ -226,11 +230,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR // TODO The retry logic is crude and may lose messages. It should record the last message like the // Android client, use since=, and do incremental backoff too if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { - log.Printf("Connection to %s failed: %s", topicURL, err.Error()) + log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error()) } select { case <-ctx.Done(): - log.Printf("Connection to %s exited", topicURL) + log.Info("%s Connection exited", util.ShortTopicURL(topicURL)) return case <-time.After(10 * time.Second): // TODO Add incremental backoff } @@ -238,7 +242,9 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR } func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil) + streamURL := fmt.Sprintf("%s/json", topicURL) + log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) if err != nil { return err } @@ -261,10 +267,12 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR } scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { - m, err := toMessage(scanner.Text(), topicURL, subscriptionID) + messageJSON := scanner.Text() + m, err := toMessage(messageJSON, topicURL, subscriptionID) if err != nil { return err } + log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON) if m.Event == MessageEvent { msgChan <- m } diff --git a/client/client.yml b/client/client.yml index 56733a14..ebf4c281 100644 --- a/client/client.yml +++ b/client/client.yml @@ -5,6 +5,21 @@ # # default-host: https://ntfy.sh +# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided. +# You can set a default token to use or a default user:password combination, but not both. For an empty password, +# use empty double-quotes (""). +# +# To override the default user:password combination or default token for a particular subscription (e.g., to send +# no Authorization header), set the user:pass/token for the subscription to empty double-quotes (""). + +# default-token: + +# default-user: +# default-password: + +# Default command will execute after "ntfy subscribe" receives a message if no command is provided in subscription below +# default-command: + # Subscriptions to topics and their actions. This option is primarily used by the systemd service, # or if you cann "ntfy subscribe --from-config" directly. # @@ -20,6 +35,8 @@ # command: 'notify-send "$m"' # user: phill # password: mypass +# - topic: token_topic +# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 # # Variables: # Variable Aliases Description diff --git a/client/client_test.go b/client/client_test.go index 4ce00670..7ab39db6 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,19 +2,26 @@ package client_test import ( "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/test" "github.com/stretchr/testify/require" - "heckel.io/ntfy/client" - "heckel.io/ntfy/test" + "os" "testing" "time" ) +func TestMain(m *testing.M) { + log.SetLevel(log.ErrorLevel) + os.Exit(m.Run()) +} + func TestClient_Publish_Subscribe(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) c := client.New(newTestConfig(port)) - subscriptionID := c.Subscribe("mytopic") + subscriptionID, _ := c.Subscribe("mytopic") time.Sleep(time.Second) msg, err := c.Publish("mytopic", "some message") diff --git a/client/config.go b/client/config.go index 0866cd1b..bc46ab89 100644 --- a/client/config.go +++ b/client/config.go @@ -12,21 +12,33 @@ const ( // Config is the config struct for a Client type Config struct { - DefaultHost string `yaml:"default-host"` - Subscribe []struct { - Topic string `yaml:"topic"` - User string `yaml:"user"` - Password string `yaml:"password"` - Command string `yaml:"command"` - If map[string]string `yaml:"if"` - } `yaml:"subscribe"` + DefaultHost string `yaml:"default-host"` + DefaultUser string `yaml:"default-user"` + DefaultPassword *string `yaml:"default-password"` + DefaultToken string `yaml:"default-token"` + DefaultCommand string `yaml:"default-command"` + Subscribe []Subscribe `yaml:"subscribe"` +} + +// Subscribe is the struct for a Subscription within Config +type Subscribe struct { + Topic string `yaml:"topic"` + User *string `yaml:"user"` + Password *string `yaml:"password"` + Token *string `yaml:"token"` + Command string `yaml:"command"` + If map[string]string `yaml:"if"` } // NewConfig creates a new Config struct for a Client func NewConfig() *Config { return &Config{ - DefaultHost: DefaultBaseURL, - Subscribe: nil, + DefaultHost: DefaultBaseURL, + DefaultUser: "", + DefaultPassword: nil, + DefaultToken: "", + DefaultCommand: "", + Subscribe: nil, } } diff --git a/client/config_test.go b/client/config_test.go index d601cdb4..f4c86bfb 100644 --- a/client/config_test.go +++ b/client/config_test.go @@ -1,8 +1,8 @@ package client_test import ( + "git.zio.sh/astra/ntfy/v2/client" "github.com/stretchr/testify/require" - "heckel.io/ntfy/client" "os" "path/filepath" "testing" @@ -12,6 +12,9 @@ func TestConfig_Load(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost +default-user: philipp +default-password: mypass +default-command: 'echo "Got the message: $message"' subscribe: - topic: no-command-with-auth user: phil @@ -22,19 +25,116 @@ subscribe: command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" if: priority: high,urgent + - topic: defaults `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) - require.Equal(t, 3, len(conf.Subscribe)) + require.Equal(t, "philipp", conf.DefaultUser) + require.Equal(t, "mypass", *conf.DefaultPassword) + require.Equal(t, `echo "Got the message: $message"`, conf.DefaultCommand) + require.Equal(t, 4, len(conf.Subscribe)) require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) require.Equal(t, "", conf.Subscribe[0].Command) - require.Equal(t, "phil", conf.Subscribe[0].User) - require.Equal(t, "mypass", conf.Subscribe[0].Password) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Equal(t, "mypass", *conf.Subscribe[0].Password) require.Equal(t, "echo-this", conf.Subscribe[1].Topic) require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command) require.Equal(t, "alerts", conf.Subscribe[2].Topic) require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command) require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"]) + require.Equal(t, "defaults", conf.Subscribe[3].Topic) +} + +func TestConfig_EmptyPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +default-password: "" +subscribe: + - topic: no-command-with-auth + user: phil + password: "" +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Equal(t, "", *conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Equal(t, "", *conf.Subscribe[0].Password) +} + +func TestConfig_NullPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +default-password: ~ +subscribe: + - topic: no-command-with-auth + user: phil + password: ~ +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) +} + +func TestConfig_NoPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +subscribe: + - topic: no-command-with-auth + user: phil +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) +} + +func TestConfig_DefaultToken(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "mytopic", conf.Subscribe[0].Topic) + require.Nil(t, conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) + require.Nil(t, conf.Subscribe[0].Token) } diff --git a/client/options.go b/client/options.go index 7d599699..1bf48faf 100644 --- a/client/options.go +++ b/client/options.go @@ -2,7 +2,7 @@ package client import ( "fmt" - "heckel.io/ntfy/util" + "git.zio.sh/astra/ntfy/v2/util" "net/http" "strings" "time" @@ -56,6 +56,11 @@ func WithClick(url string) PublishOption { return WithHeader("X-Click", url) } +// WithIcon makes the notification use the given URL as its icon +func WithIcon(icon string) PublishOption { + return WithHeader("X-Icon", icon) +} + // WithActions adds custom user actions to the notification. The value can be either a JSON array or the // simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details. func WithActions(value string) PublishOption { @@ -67,6 +72,11 @@ func WithAttach(attach string) PublishOption { return WithHeader("X-Attach", attach) } +// WithMarkdown instructs the server to interpret the message body as Markdown +func WithMarkdown() PublishOption { + return WithHeader("X-Markdown", "yes") +} + // WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) @@ -82,6 +92,16 @@ func WithBasicAuth(user, pass string) PublishOption { return WithHeader("Authorization", util.BasicAuth(user, pass)) } +// WithBearerAuth adds the Authorization header for Bearer auth to the request +func WithBearerAuth(token string) PublishOption { + return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token)) +} + +// WithEmptyAuth clears the Authorization header +func WithEmptyAuth() PublishOption { + return RemoveHeader("Authorization") +} + // WithNoCache instructs the server not to cache the message server-side func WithNoCache() PublishOption { return WithHeader("X-Cache", "no") @@ -172,3 +192,13 @@ func WithQueryParam(param, value string) RequestOption { return nil } } + +// RemoveHeader is a generic option to remove a header from a request +func RemoveHeader(header string) RequestOption { + return func(r *http.Request) error { + if header != "" { + delete(r.Header, header) + } + return nil + } +} diff --git a/cmd/access.go b/cmd/access.go index b3cacfa5..0dc4719e 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -1,19 +1,25 @@ +//go:build !noserver + package cmd import ( "errors" "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" "github.com/urfave/cli/v2" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" ) +func init() { + commands = append(commands, cmdAccess) +} + const ( userEveryone = "everyone" ) var flagsAccess = append( - userCommandFlags(), + append([]cli.Flag{}, flagsUser...), &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, ) @@ -22,7 +28,7 @@ var cmdAccess = &cli.Command{ Usage: "Grant/revoke access to a topic, or show access", UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", Flags: flagsAccess, - Before: initConfigFileInputSource("config", flagsAccess), + Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc), Action: execUserAccess, Category: categoryServer, Description: `Manage the access control list for the ntfy server. @@ -65,13 +71,13 @@ func execUserAccess(c *cli.Context) error { if c.NArg() > 3 { return errors.New("too many arguments, please check 'ntfy access --help' for usage details") } - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } username := c.Args().Get(0) if username == userEveryone { - username = auth.Everyone + username = user.Everyone } topic := c.Args().Get(1) perms := c.Args().Get(2) @@ -90,26 +96,28 @@ func execUserAccess(c *cli.Context) error { return changeAccess(c, manager, username, topic, perms) } -func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error { - if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) { +func changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error { + if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) { return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)") } - read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms) - write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms) - user, err := manager.User(username) - if err == auth.ErrNotFound { - return fmt.Errorf("user %s does not exist", username) - } else if user.Role == auth.RoleAdmin { - return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) - } - if err := manager.AllowAccess(username, topic, read, write); err != nil { + permission, err := user.ParsePermission(perms) + if err != nil { return err } - if read && write { + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if u.Role == user.RoleAdmin { + return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) + } + if err := manager.AllowAccess(username, topic, permission); err != nil { + return err + } + if permission.IsReadWrite() { fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) - } else if read { + } else if permission.IsRead() { fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) - } else if write { + } else if permission.IsWrite() { fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) } else { fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) @@ -117,7 +125,7 @@ func changeAccess(c *cli.Context, manager auth.Manager, username string, topic s return showUserAccess(c, manager, username) } -func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error { +func resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error { if username == "" { return resetAllAccess(c, manager) } else if topic == "" { @@ -126,7 +134,7 @@ func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) e return resetUserTopicAccess(c, manager, username, topic) } -func resetAllAccess(c *cli.Context, manager auth.Manager) error { +func resetAllAccess(c *cli.Context, manager *user.Manager) error { if err := manager.ResetAccess("", ""); err != nil { return err } @@ -134,7 +142,7 @@ func resetAllAccess(c *cli.Context, manager auth.Manager) error { return nil } -func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error { +func resetUserAccess(c *cli.Context, manager *user.Manager, username string) error { if err := manager.ResetAccess(username, ""); err != nil { return err } @@ -142,7 +150,7 @@ func resetUserAccess(c *cli.Context, manager auth.Manager, username string) erro return showUserAccess(c, manager, username) } -func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error { +func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error { if err := manager.ResetAccess(username, topic); err != nil { return err } @@ -150,14 +158,14 @@ func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, return showUserAccess(c, manager, username) } -func showAccess(c *cli.Context, manager auth.Manager, username string) error { +func showAccess(c *cli.Context, manager *user.Manager, username string) error { if username == "" { return showAllAccess(c, manager) } return showUserAccess(c, manager, username) } -func showAllAccess(c *cli.Context, manager auth.Manager) error { +func showAllAccess(c *cli.Context, manager *user.Manager) error { users, err := manager.Users() if err != nil { return err @@ -165,28 +173,36 @@ func showAllAccess(c *cli.Context, manager auth.Manager) error { return showUsers(c, manager, users) } -func showUserAccess(c *cli.Context, manager auth.Manager, username string) error { +func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { users, err := manager.User(username) - if err == auth.ErrNotFound { + if err == user.ErrUserNotFound { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } - return showUsers(c, manager, []*auth.User{users}) + return showUsers(c, manager, []*user.User{users}) } -func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error { - for _, user := range users { - fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role) - if user.Role == auth.RoleAdmin { +func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error { + for _, u := range users { + grants, err := manager.Grants(u.Name) + if err != nil { + return err + } + tier := "none" + if u.Tier != nil { + tier = u.Tier.Name + } + fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier) + if u.Role == user.RoleAdmin { fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") - } else if len(user.Grants) > 0 { - for _, grant := range user.Grants { - if grant.AllowRead && grant.AllowWrite { + } else if len(grants) > 0 { + for _, grant := range grants { + if grant.Allow.IsReadWrite() { fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) - } else if grant.AllowRead { + } else if grant.Allow.IsRead() { fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) - } else if grant.AllowWrite { + } else if grant.Allow.IsWrite() { fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) } else { fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) @@ -195,13 +211,13 @@ func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error { } else { fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") } - if user.Name == auth.Everyone { - defaultRead, defaultWrite := manager.DefaultAccess() - if defaultRead && defaultWrite { + if u.Name == user.Everyone { + access := manager.DefaultAccess() + if access.IsReadWrite() { fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") - } else if defaultRead { + } else if access.IsRead() { fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") - } else if defaultWrite { + } else if access.IsWrite() { fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") } else { fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") diff --git a/cmd/access_test.go b/cmd/access_test.go index 67159d43..d872021a 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -2,10 +2,10 @@ package cmd import ( "fmt" + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "heckel.io/ntfy/server" - "heckel.io/ntfy/test" "testing" ) @@ -15,7 +15,7 @@ func TestCLI_Access_Show(t *testing.T) { app, _, _, stderr := newTestApp() require.Nil(t, runAccessCommand(app, conf)) - require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") + require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") } func TestCLI_Access_Grant_And_Publish(t *testing.T) { @@ -32,12 +32,12 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) { app, _, _, stderr := newTestApp() require.Nil(t, runAccessCommand(app, conf)) - expected := `user phil (admin) + expected := `user phil (role: admin, tier: none) - read-write access to all topics (admin role) -user ben (user) +user ben (role: user, tier: none) - read-write access to topic announcements - read-only access to topic sometopic -user * (anonymous) +user * (role: anonymous, tier: none) - read-only access to topic announcements - no access to any (other) topics (server config) ` @@ -79,9 +79,11 @@ user * (anonymous) func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", + "--log-level=ERROR", "access", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, - "--auth-default-access=" + confToDefaultAccess(conf), + "--auth-default-access=" + conf.AuthDefault.String(), } return app.Run(append(userArgs, args...)) } diff --git a/cmd/app.go b/cmd/app.go index 85540cee..27e876b6 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -3,15 +3,11 @@ package cmd import ( "fmt" + "git.zio.sh/astra/ntfy/v2/log" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/util" "os" -) - -var ( - defaultClientRootConfigFile = "/etc/ntfy/client.yml" - defaultClientUserConfigFile = "~/.config/ntfy/client.yml" + "regexp" ) const ( @@ -19,6 +15,22 @@ const ( categoryServer = "Server commands" ) +var commands = make([]*cli.Command, 0) + +var flagsDefault = []cli.Flag{ + &cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"}, + &cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"}, + &cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}), +} + +var ( + logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`) +) + // New creates a new CLI application func New() *cli.App { return &cli.App{ @@ -30,33 +42,49 @@ func New() *cli.App { Reader: os.Stdin, Writer: os.Stdout, ErrWriter: os.Stderr, - Commands: []*cli.Command{ - // Server commands - cmdServe, - cmdUser, - cmdAccess, - - // Client commands - cmdPublish, - cmdSubscribe, - }, + Commands: commands, + Flags: flagsDefault, + Before: initLogFunc, } } -// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks -// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. -func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc { - return func(context *cli.Context) error { - configFile := context.String(configFlag) - if context.IsSet(configFlag) && !util.FileExists(configFile) { - return fmt.Errorf("config file %s does not exist", configFile) - } else if !context.IsSet(configFlag) && !util.FileExists(configFile) { - return nil - } - inputSource, err := altsrc.NewYamlSourceFromFile(configFile) +func initLogFunc(c *cli.Context) error { + log.SetLevel(log.ToLevel(c.String("log-level"))) + log.SetFormat(log.ToFormat(c.String("log-format"))) + if c.Bool("trace") { + log.SetLevel(log.TraceLevel) + } else if c.Bool("debug") { + log.SetLevel(log.DebugLevel) + } + if c.Bool("no-log-dates") { + log.DisableDates() + } + if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil { + return err + } + logFile := c.String("log-file") + if logFile != "" { + w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return err } - return altsrc.ApplyInputSourceValues(context, inputSource, flags) + log.SetOutput(w) } + return nil +} + +func applyLogLevelOverrides(rawOverrides []string) error { + for _, override := range rawOverrides { + m := logLevelOverrideRegex.FindStringSubmatch(override) + if len(m) == 4 { + field, value, level := m[1], m[2], m[3] + log.SetLevelOverride(field, value, log.ToLevel(level)) + } else if len(m) == 3 { + field, level := m[1], m[2] + log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value + } else { + return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override) + } + } + return nil } diff --git a/cmd/app_test.go b/cmd/app_test.go index 9873dd09..c5232050 100644 --- a/cmd/app_test.go +++ b/cmd/app_test.go @@ -3,8 +3,9 @@ package cmd import ( "bytes" "encoding/json" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" "github.com/urfave/cli/v2" - "heckel.io/ntfy/client" "os" "strings" "testing" @@ -13,7 +14,7 @@ import ( // This only contains helpers so far func TestMain(m *testing.M) { - // log.SetOutput(io.Discard) + log.SetLevel(log.ErrorLevel) os.Exit(m.Run()) } diff --git a/cmd/config_loader.go b/cmd/config_loader.go new file mode 100644 index 00000000..eab742e2 --- /dev/null +++ b/cmd/config_loader.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "gopkg.in/yaml.v2" + "os" +) + +// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks +// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. +func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc { + return func(context *cli.Context) error { + configFile := context.String(configFlag) + if context.IsSet(configFlag) && !util.FileExists(configFile) { + return fmt.Errorf("config file %s does not exist", configFile) + } else if !context.IsSet(configFlag) && !util.FileExists(configFile) { + return nil + } + inputSource, err := newYamlSourceFromFile(configFile, flags) + if err != nil { + return err + } + if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil { + return err + } + if next != nil { + if err := next(context); err != nil { + return err + } + } + return nil + } +} + +// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath. +// +// This function also maps aliases, so a .yml file can contain short options, or options with underscores +// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255. +func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) { + var rawConfig map[any]any + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(b, &rawConfig); err != nil { + return nil, err + } + for _, f := range flags { + flagName := f.Names()[0] + for _, flagAlias := range f.Names()[1:] { + if _, ok := rawConfig[flagAlias]; ok { + rawConfig[flagName] = rawConfig[flagAlias] + } + } + } + return altsrc.NewMapInputSource(file, rawConfig), nil +} diff --git a/cmd/config_loader_test.go b/cmd/config_loader_test.go new file mode 100644 index 00000000..7a7f2bf1 --- /dev/null +++ b/cmd/config_loader_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestNewYamlSourceFromFile(t *testing.T) { + filename := filepath.Join(t.TempDir(), "server.yml") + contents := ` +# Normal options +listen-https: ":10443" + +# Note the underscore! +listen_http: ":1080" + +# OMG this is allowed now ... +K: /some/file.pem +` + require.Nil(t, os.WriteFile(filename, []byte(contents), 0600)) + + ctx, err := newYamlSourceFromFile(filename, flagsServe) + require.Nil(t, err) + + listenHTTPS, err := ctx.String("listen-https") + require.Nil(t, err) + require.Equal(t, ":10443", listenHTTPS) + + listenHTTP, err := ctx.String("listen-http") // No underscore! + require.Nil(t, err) + require.Equal(t, ":1080", listenHTTP) + + keyFile, err := ctx.String("key-file") // Long option! + require.Nil(t, err) + require.Equal(t, "/some/file.pem", keyFile) +} diff --git a/cmd/publish.go b/cmd/publish.go index e210308a..050184ca 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -3,40 +3,58 @@ package cmd import ( "errors" "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" "github.com/urfave/cli/v2" - "heckel.io/ntfy/client" - "heckel.io/ntfy/util" "io" "os" + "os/exec" "path/filepath" "strings" + "time" +) + +func init() { + commands = append(commands, cmdPublish) +} + +var flagsPublish = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, + &cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, + &cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"}, + &cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, + &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, + &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, + &cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, + &cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"}, + &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, + &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, + &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, + &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, + &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, + &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, + &cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"}, + &cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"}, + &cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, + &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, + &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"}, ) var cmdPublish = &cli.Command{ - Name: "publish", - Aliases: []string{"pub", "send", "trigger"}, - Usage: "Send message via a ntfy server", - UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]", - Action: execPublish, - Category: categoryClient, - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, - &cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, - &cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, - &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, - &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, - &cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, - &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, - &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, - &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, - &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, - &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, - &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, - &cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, - &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, - &cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"}, - &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"}, - }, + Name: "publish", + Aliases: []string{"pub", "send", "trigger"}, + Usage: "Send message via a ntfy server", + UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...] +ntfy publish [OPTIONS..] --wait-cmd COMMAND... +NTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`, + Action: execPublish, + Category: categoryClient, + Flags: flagsPublish, + Before: initLogFunc, Description: `Publish a message to a ntfy server. Examples: @@ -48,19 +66,21 @@ Examples: ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked + ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment ntfy pub -u phil:mypass secret Psst # Publish with username/password + ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing + ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password - NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic + NTFY_TOPIC=mytopic ntfy pub "some message" # Use NTFY_TOPIC variable as topic cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment ntfy trigger mywebhook # Sending without message, useful for webhooks Please also check out the docs on publishing messages. Especially for the --tags and --delay options, it has incredibly useful information: https://ntfy.sh/docs/publish/. -The default config file for all client commands is /etc/ntfy/client.yml (if root user), -or ~/.config/ntfy/client.yml for all other users.`, +` + clientCommandDescriptionSuffix, } func execPublish(c *cli.Context) error { @@ -73,30 +93,29 @@ func execPublish(c *cli.Context) error { tags := c.String("tags") delay := c.String("delay") click := c.String("click") + icon := c.String("icon") actions := c.String("actions") attach := c.String("attach") + markdown := c.Bool("markdown") filename := c.String("filename") file := c.String("file") email := c.String("email") user := c.String("user") + token := c.String("token") noCache := c.Bool("no-cache") noFirebase := c.Bool("no-firebase") - envTopic := c.Bool("env-topic") quiet := c.Bool("quiet") - var topic, message string - if envTopic { - topic = os.Getenv("NTFY_TOPIC") - if c.NArg() > 0 { - message = strings.Join(c.Args().Slice(), " ") - } - } else { - if c.NArg() < 1 { - return errors.New("must specify topic, type 'ntfy publish --help' for help") - } - topic = c.Args().Get(0) - if c.NArg() > 1 { - message = strings.Join(c.Args().Slice()[1:], " ") - } + pid := c.Int("wait-pid") + + // Checks + if user != "" && token != "" { + return errors.New("cannot set both --user and --token") + } + + // Do the things + topic, message, command, err := parseTopicMessageCommand(c) + if err != nil { + return err } var options []client.PublishOption if title != "" { @@ -114,12 +133,18 @@ func execPublish(c *cli.Context) error { if click != "" { options = append(options, client.WithClick(click)) } + if icon != "" { + options = append(options, client.WithIcon(icon)) + } if actions != "" { options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " "))) } if attach != "" { options = append(options, client.WithAttach(attach)) } + if markdown { + options = append(options, client.WithMarkdown()) + } if filename != "" { options = append(options, client.WithFilename(filename)) } @@ -132,7 +157,9 @@ func execPublish(c *cli.Context) error { if noFirebase { options = append(options, client.WithNoFirebase()) } - if user != "" { + if token != "" { + options = append(options, client.WithBearerAuth(token)) + } else if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { @@ -148,6 +175,25 @@ func execPublish(c *cli.Context) error { fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) + } else if conf.DefaultToken != "" { + options = append(options, client.WithBearerAuth(conf.DefaultToken)) + } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { + options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) + } + if pid > 0 { + newMessage, err := waitForProcess(pid) + if err != nil { + return err + } else if message == "" { + message = newMessage + } + } else if len(command) > 0 { + newMessage, err := runAndWaitForCommand(command) + if err != nil { + return err + } else if message == "" { + message = newMessage + } } var body io.Reader if file == "" { @@ -181,3 +227,88 @@ func execPublish(c *cli.Context) error { } return nil } + +// parseTopicMessageCommand reads the topic and the remaining arguments from the context. + +// There are a few cases to consider: +// +// ntfy publish [] +// ntfy publish --wait-cmd +// NTFY_TOPIC=.. ntfy publish [] +// NTFY_TOPIC=.. ntfy publish --wait-cmd +func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) { + var args []string + topic, args, err = parseTopicAndArgs(c) + if err != nil { + return + } + if c.Bool("wait-cmd") { + if len(args) == 0 { + err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help") + return + } + command = args + } else { + message = strings.Join(args, " ") + } + if c.String("message") != "" { + message = c.String("message") + } + return +} + +func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) { + envTopic := os.Getenv("NTFY_TOPIC") + if envTopic != "" { + topic = envTopic + return topic, remainingArgs(c, 0), nil + } + if c.NArg() < 1 { + return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help") + } + return c.Args().Get(0), remainingArgs(c, 1), nil +} + +func remainingArgs(c *cli.Context, fromIndex int) []string { + if c.NArg() > fromIndex { + return c.Args().Slice()[fromIndex:] + } + return []string{} +} + +func waitForProcess(pid int) (message string, err error) { + if !processExists(pid) { + return "", fmt.Errorf("process with PID %d not running", pid) + } + start := time.Now() + log.Debug("Waiting for process with PID %d to exit", pid) + for processExists(pid) { + time.Sleep(500 * time.Millisecond) + } + runtime := time.Since(start).Round(time.Millisecond) + log.Debug("Process with PID %d exited after %s", pid, runtime) + return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil +} + +func runAndWaitForCommand(command []string) (message string, err error) { + prettyCmd := util.QuoteCommand(command) + log.Debug("Running command: %s", prettyCmd) + start := time.Now() + cmd := exec.Command(command[0], command[1:]...) + if log.IsTrace() { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + err = cmd.Run() + runtime := time.Since(start).Round(time.Millisecond) + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd) + return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil + } + // Hard fail when command does not exist or could not be properly launched + return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error()) + } + log.Debug("Command succeeded after %s: %s", runtime, prettyCmd) + return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil +} diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 23d2d36d..fb4bbc70 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -2,21 +2,36 @@ package cmd import ( "fmt" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/util" "github.com/stretchr/testify/require" - "heckel.io/ntfy/test" - "heckel.io/ntfy/util" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" "testing" + "time" ) func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { testMessage := util.RandomString(10) - app, _, _, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) - app2, _, stdout, _ := newTestApp() - require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) - require.Contains(t, stdout.String(), testMessage) + _, err := util.Retry(func() (*int, error) { + app2, _, stdout, _ := newTestApp() + if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil { + return nil, err + } + if !strings.Contains(stdout.String(), testMessage) { + return nil, fmt.Errorf("test message %s not found in topic", testMessage) + } + return util.Int(1), nil + }, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s + require.Nil(t, err) } func TestCLI_Publish_Subscribe_Poll(t *testing.T) { @@ -48,6 +63,7 @@ func TestCLI_Publish_All_The_Things(t *testing.T) { "--tags", "tag1,tag2", // No --delay, --email "--click", "https://ntfy.sh", + "--icon", "https://ntfy.sh/static/img/ntfy.png", "--attach", "https://f-droid.org/F-Droid.apk", "--filename", "fdroid.apk", "--no-cache", @@ -69,4 +85,216 @@ func TestCLI_Publish_All_The_Things(t *testing.T) { require.Equal(t, "", m.Attachment.Owner) require.Equal(t, int64(0), m.Attachment.Expires) require.Equal(t, "", m.Attachment.Type) + require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) +} + +func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + // Test: sleep 0.5 + sleep := exec.Command("sleep", "0.5") + require.Nil(t, sleep.Start()) + go sleep.Wait() // Must be called to release resources + start := time.Now() + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic})) + m := toMessage(t, stdout.String()) + require.True(t, time.Since(start) >= 500*time.Millisecond) + require.Regexp(t, `Process with PID \d+ exited after `, m.Message) + + // Test: PID does not exist + app, _, _, _ = newTestApp() + err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic}) + require.Error(t, err) + require.Equal(t, "process with PID 1234567 not running", err.Error()) + + // Test: Successful command (exit 0) + start = time.Now() + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"})) + m = toMessage(t, stdout.String()) + require.True(t, time.Since(start) >= 500*time.Millisecond) + require.Contains(t, m.Message, `Command succeeded after `) + require.Contains(t, m.Message, `: sleep 0.5`) + + // Test: Failing command (exit 1) + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"})) + m = toMessage(t, stdout.String()) + require.Contains(t, m.Message, `Command failed after `) + require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message) + + // Test: Non-existing command (hard fail!) + app, _, _, _ = newTestApp() + err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"}) + require.Error(t, err) + require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error()) + + // Tests with NTFY_TOPIC set //// + t.Setenv("NTFY_TOPIC", topic) + + // Test: Successful command with NTFY_TOPIC + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"})) + m = toMessage(t, stdout.String()) + require.Equal(t, "mytopic", m.Topic) + + // Test: Successful --wait-pid with NTFY_TOPIC + sleep = exec.Command("sleep", "0.2") + require.Nil(t, sleep.Start()) + go sleep.Wait() // Must be called to release resources + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)})) + m = toMessage(t, stdout.String()) + require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message) +} + +func TestCLI_Publish_Default_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: fakepass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) } diff --git a/cmd/publish_unix.go b/cmd/publish_unix.go new file mode 100644 index 00000000..3ce22ffc --- /dev/null +++ b/cmd/publish_unix.go @@ -0,0 +1,11 @@ +//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd +// +build darwin linux dragonfly freebsd netbsd openbsd + +package cmd + +import "syscall" + +func processExists(pid int) bool { + err := syscall.Kill(pid, syscall.Signal(0)) + return err == nil +} diff --git a/cmd/publish_windows.go b/cmd/publish_windows.go new file mode 100644 index 00000000..92ce3112 --- /dev/null +++ b/cmd/publish_windows.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "os" +) + +func processExists(pid int) bool { + _, err := os.FindProcess(pid) + return err == nil +} diff --git a/cmd/serve.go b/cmd/serve.go index 2fd878d5..1177af0c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -1,58 +1,106 @@ +//go:build !noserver + package cmd import ( "errors" "fmt" - "github.com/urfave/cli/v2" - "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/server" - "heckel.io/ntfy/util" - "log" + "git.zio.sh/astra/ntfy/v2/user" + "github.com/stripe/stripe-go/v74" + "io/fs" "math" "net" + "net/netip" + "os" + "os/signal" "strings" + "syscall" "time" + + "git.zio.sh/astra/ntfy/v2/log" + + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" ) -var flagsServe = []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, - altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), - altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), +func init() { + commands = append(commands, cmdServe) } +const ( + defaultServerConfigFile = "/etc/ntfy/server.yml" +) + +var flagsServe = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), +) + var cmdServe = &cli.Command{ Name: "serve", Usage: "Run the ntfy server", @@ -60,7 +108,7 @@ var cmdServe = &cli.Command{ Action: execServe, Category: categoryServer, Flags: flagsServe, - Before: initConfigFileInputSource("config", flagsServe), + Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc), Description: `Run the ntfy server and listen for incoming requests The command will load the configuration from /etc/ntfy/server.yml. Config options can @@ -77,16 +125,27 @@ func execServe(c *cli.Context) error { } // Read all the options + config := c.String("config") baseURL := c.String("base-url") listenHTTP := c.String("listen-http") listenHTTPS := c.String("listen-https") listenUnix := c.String("listen-unix") + listenUnixMode := c.Int("listen-unix-mode") keyFile := c.String("key-file") certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") + webPushPrivateKey := c.String("web-push-private-key") + webPushPublicKey := c.String("web-push-public-key") + webPushFile := c.String("web-push-file") + webPushEmailAddress := c.String("web-push-email-address") + webPushStartupQueries := c.String("web-push-startup-queries") cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") + cacheStartupQueries := c.String("cache-startup-queries") + cacheBatchSize := c.Int("cache-batch-size") + cacheBatchTimeout := c.Duration("cache-batch-timeout") authFile := c.String("auth-file") + authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") @@ -94,7 +153,13 @@ func execServe(c *cli.Context) error { attachmentExpiryDuration := c.Duration("attachment-expiry-duration") keepaliveInterval := c.Duration("keepalive-interval") managerInterval := c.Duration("manager-interval") + disallowedTopics := c.StringSlice("disallowed-topics") webRoot := c.String("web-root") + enableSignup := c.Bool("enable-signup") + enableLogin := c.Bool("enable-login") + enableReservations := c.Bool("enable-reservations") + upstreamBaseURL := c.String("upstream-base-url") + upstreamAccessToken := c.String("upstream-access-token") smtpSenderAddr := c.String("smtp-sender-addr") smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") @@ -102,20 +167,34 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") + twilioAccount := c.String("twilio-account") + twilioAuthToken := c.String("twilio-auth-token") + twilioPhoneNumber := c.String("twilio-phone-number") + twilioVerifyService := c.String("twilio-verify-service") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") + visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") + visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") behindProxy := c.Bool("behind-proxy") + stripeSecretKey := c.String("stripe-secret-key") + stripeWebhookKey := c.String("stripe-webhook-key") + billingContact := c.String("billing-contact") + metricsListenHTTP := c.String("metrics-listen-http") + enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" + profileListenHTTP := c.String("profile-listen-http") // Check values if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { return errors.New("if set, FCM key file must exist") + } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") { + return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { @@ -128,24 +207,50 @@ func execServe(c *cli.Context) error { return errors.New("if set, certificate file must exist") } else if listenHTTPS != "" && (keyFile == "" || certFile == "") { return errors.New("if listen-https is set, both key-file and cert-file must be set") - } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") { - return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set") + } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { + return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { return errors.New("if set, base-url must start with http:// or https://") - } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { - return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") - } else if !util.InStringList([]string{"app", "home"}, webRoot) { - return errors.New("if set, web-root must be 'home' or 'app'") + } else if baseURL != "" && strings.HasSuffix(baseURL, "/") { + return errors.New("if set, base-url must not end with a slash (/)") + } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { + return errors.New("if set, upstream-base-url must start with http:// or https://") + } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { + return errors.New("if set, upstream-base-url must not end with a slash (/)") + } else if upstreamBaseURL != "" && baseURL == "" { + return errors.New("if upstream-base-url is set, base-url must also be set") + } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { + return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") + } else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") { + return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") + } else if enableSignup && !enableLogin { + return errors.New("cannot set enable-signup without also setting enable-login") + } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { + return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { + return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") + } + + // Backwards compatibility + if webRoot == "app" { + webRoot = "/" + } else if webRoot == "home" { + webRoot = "/app" + } else if webRoot == "disable" { + webRoot = "" + } else if !strings.HasPrefix(webRoot, "/") { + webRoot = "/" + webRoot } // Default auth permissions - webRootIsApp := webRoot == "app" - authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" - authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" + authDefault, err := user.ParsePermission(authDefaultAccess) + if err != nil { + return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") + } // Special case: Unset default if listenHTTP == "-" { @@ -173,39 +278,54 @@ func execServe(c *cli.Context) error { } // Resolve hosts - visitorRequestLimitExemptIPs := make([]string, 0) + visitorRequestLimitExemptIPs := make([]netip.Prefix, 0) for _, host := range visitorRequestLimitExemptHosts { - ips, err := net.LookupIP(host) + ips, err := parseIPHostPrefix(host) if err != nil { - log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) + log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) continue } - for _, ip := range ips { - visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String()) - } + visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...) } + // Stripe things + if stripeSecretKey != "" { + stripe.EnableTelemetry = false // Whoa! + stripe.Key = stripeSecretKey + } + + // Add default forbidden topics + disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) + // Run server conf := server.NewConfig() + conf.File = config conf.BaseURL = baseURL conf.ListenHTTP = listenHTTP conf.ListenHTTPS = listenHTTPS conf.ListenUnix = listenUnix + conf.ListenUnixMode = fs.FileMode(listenUnixMode) conf.KeyFile = keyFile conf.CertFile = certFile conf.FirebaseKeyFile = firebaseKeyFile conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration + conf.CacheStartupQueries = cacheStartupQueries + conf.CacheBatchSize = cacheBatchSize + conf.CacheBatchTimeout = cacheBatchTimeout conf.AuthFile = authFile - conf.AuthDefaultRead = authDefaultRead - conf.AuthDefaultWrite = authDefaultWrite + conf.AuthStartupQueries = authStartupQueries + conf.AuthDefault = authDefault conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval - conf.WebRootIsApp = webRootIsApp + conf.DisallowedTopics = disallowedTopics + conf.WebRoot = webRoot + conf.UpstreamBaseURL = upstreamBaseURL + conf.UpstreamAccessToken = upstreamAccessToken conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass @@ -213,24 +333,49 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix + conf.TwilioAccount = twilioAccount + conf.TwilioAuthToken = twilioAuthToken + conf.TwilioPhoneNumber = twilioPhoneNumber + conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit - conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit) + conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs + conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy + conf.StripeSecretKey = stripeSecretKey + conf.StripeWebhookKey = stripeWebhookKey + conf.BillingContact = billingContact + conf.EnableSignup = enableSignup + conf.EnableLogin = enableLogin + conf.EnableReservations = enableReservations + conf.EnableMetrics = enableMetrics + conf.MetricsListenHTTP = metricsListenHTTP + conf.ProfileListenHTTP = profileListenHTTP + conf.Version = c.App.Version + conf.WebPushPrivateKey = webPushPrivateKey + conf.WebPushPublicKey = webPushPublicKey + conf.WebPushFile = webPushFile + conf.WebPushEmailAddress = webPushEmailAddress + conf.WebPushStartupQueries = webPushStartupQueries + + // Set up hot-reloading of config + go sigHandlerConfigReload(config) + + // Run server s, err := server.New(conf) if err != nil { - log.Fatalln(err) + log.Fatal(err.Error()) + } else if err := s.Run(); err != nil { + log.Fatal(err.Error()) } - if err := s.Run(); err != nil { - log.Fatalln(err) - } - log.Printf("Exiting.") + log.Info("Exiting.") return nil } @@ -244,3 +389,66 @@ func parseSize(s string, defaultValue int64) (v int64, err error) { } return v, nil } + +func sigHandlerConfigReload(config string) { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP) + for range sigs { + log.Info("Partially hot reloading configuration ...") + inputSource, err := newYamlSourceFromFile(config, flagsServe) + if err != nil { + log.Warn("Hot reload failed: %s", err.Error()) + continue + } + if err := reloadLogLevel(inputSource); err != nil { + log.Warn("Reloading log level failed: %s", err.Error()) + } + } +} + +func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { + // Try parsing as prefix, e.g. 10.0.1.0/24 + prefix, err := netip.ParsePrefix(host) + if err == nil { + prefixes = append(prefixes, prefix.Masked()) + return prefixes, nil + } + // Not a prefix, parse as host or IP (LookupHost passes through an IP as is) + ips, err := net.LookupHost(host) + if err != nil { + return nil, err + } + for _, ipStr := range ips { + ip, err := netip.ParseAddr(ipStr) + if err == nil { + prefix, err := ip.Prefix(ip.BitLen()) + if err != nil { + return nil, fmt.Errorf("%s successfully parsed but unable to make prefix: %s", ip.String(), err.Error()) + } + prefixes = append(prefixes, prefix.Masked()) + } + } + return +} + +func reloadLogLevel(inputSource altsrc.InputSourceContext) error { + newLevelStr, err := inputSource.String("log-level") + if err != nil { + return fmt.Errorf("cannot load log level: %s", err.Error()) + } + overrides, err := inputSource.StringSlice("log-level-overrides") + if err != nil { + return fmt.Errorf("cannot load log level overrides (1): %s", err.Error()) + } + log.ResetLevelOverrides() + if err := applyLogLevelOverrides(overrides); err != nil { + return fmt.Errorf("cannot load log level overrides (2): %s", err.Error()) + } + log.SetLevel(log.ToLevel(newLevelStr)) + if len(overrides) > 0 { + log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides)) + } else { + log.Info("Log level is %v", strings.ToUpper(newLevelStr)) + } + return nil +} diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 3b2b9b0d..2fef0643 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -2,17 +2,19 @@ package cmd import ( "fmt" - "github.com/gorilla/websocket" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/client" - "heckel.io/ntfy/test" - "heckel.io/ntfy/util" "math/rand" "os" "os/exec" "path/filepath" "testing" "time" + + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func init() { @@ -70,6 +72,22 @@ func TestCLI_Serve_WebSocket(t *testing.T) { require.Equal(t, "mytopic", m.Topic) } +func TestIP_Host_Parsing(t *testing.T) { + cases := map[string]string{ + "1.1.1.1": "1.1.1.1/32", + "fd00::1234": "fd00::1234/128", + "192.168.0.3/24": "192.168.0.0/24", + "10.1.2.3/8": "10.0.0.0/8", + "201:be93::4a6/21": "201:b800::/21", + } + for q, expectedAnswer := range cases { + ips, err := parseIPHostPrefix(q) + require.Nil(t, err) + assert.Equal(t, 1, len(ips)) + assert.Equal(t, expectedAnswer, ips[0].String()) + } +} + func newEmptyFile(t *testing.T) string { filename := filepath.Join(t.TempDir(), "empty") require.Nil(t, os.WriteFile(filename, []byte{}, 0600)) diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 9000a163..c9ed75e9 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -3,16 +3,39 @@ package cmd import ( "errors" "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" "github.com/urfave/cli/v2" - "heckel.io/ntfy/client" - "heckel.io/ntfy/util" - "log" "os" "os/exec" "os/user" + "path/filepath" + "sort" "strings" ) +func init() { + commands = append(commands, cmdSubscribe) +} + +const ( + clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml" + clientUserConfigFileUnixRelative = "ntfy/client.yml" + clientUserConfigFileWindowsRelative = "ntfy\\client.yml" +) + +var flagsSubscribe = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, + &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, + &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, + &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"}, + &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, + &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, +) + var cmdSubscribe = &cli.Command{ Name: "subscribe", Aliases: []string{"sub"}, @@ -20,15 +43,8 @@ var cmdSubscribe = &cli.Command{ UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", Action: execSubscribe, Category: categoryClient, - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, - &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, - &cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"}, - &cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"}, - &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, - &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"}, - }, + Flags: flagsSubscribe, + Before: initLogFunc, Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for every arriving message. There are 3 modes in which the command can be run: @@ -56,23 +72,21 @@ ntfy subscribe TOPIC COMMAND $NTFY_TITLE $title, $t Message title $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) - $NTFY_RAW $raw Raw JSON message + $NTFY_RAW $raw Raw JSON message Examples: ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages - ntfy sub topic1 /my/script.sh # Execute script for incoming messages + ntfy sub topic1 myscript.sh # Execute script for incoming messages ntfy subscribe --from-config - Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml - or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:" - block (see config file). + Service mode (used in ntfy-client.service). This reads the config file and sets up + subscriptions for every topic in the "subscribe:" block (see config file). Examples: ntfy sub --from-config # Read topics from config file - ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file + ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file -The default config file for all client commands is /etc/ntfy/client.yml (if root user), -or ~/.config/ntfy/client.yml for all other users.`, +` + clientCommandDescriptionSuffix, } func execSubscribe(c *cli.Context) error { @@ -84,11 +98,18 @@ func execSubscribe(c *cli.Context) error { cl := client.New(conf) since := c.String("since") user := c.String("user") + token := c.String("token") poll := c.Bool("poll") scheduled := c.Bool("scheduled") fromConfig := c.Bool("from-config") topic := c.Args().Get(0) command := c.Args().Get(1) + + // Checks + if user != "" && token != "" { + return errors.New("cannot set both --user and --token") + } + if !fromConfig { conf.Subscribe = nil // wipe if --from-config not passed } @@ -96,7 +117,9 @@ func execSubscribe(c *cli.Context) error { if since != "" { options = append(options, client.WithSince(since)) } - if user != "" { + if token != "" { + options = append(options, client.WithBearerAuth(token)) + } else if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { @@ -112,9 +135,10 @@ func execSubscribe(c *cli.Context) error { fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) - } - if poll { - options = append(options, client.WithPoll()) + } else if conf.DefaultToken != "" { + options = append(options, client.WithBearerAuth(conf.DefaultToken)) + } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { + options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } if scheduled { options = append(options, client.WithScheduled()) @@ -132,6 +156,9 @@ func execSubscribe(c *cli.Context) error { func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { for _, s := range conf.Subscribe { // may be nil + if auth := maybeAddAuthHeader(s, conf); auth != nil { + options = append(options, auth) + } if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { return err } @@ -156,28 +183,67 @@ func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, opti } func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { - commands := make(map[string]string) // Subscription ID -> command - for _, s := range conf.Subscribe { // May be nil + cmds := make(map[string]string) // Subscription ID -> command + for _, s := range conf.Subscribe { // May be nil topicOptions := append(make([]client.SubscribeOption, 0), options...) for filter, value := range s.If { topicOptions = append(topicOptions, client.WithFilter(filter, value)) } - if s.User != "" && s.Password != "" { - topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password)) + + if auth := maybeAddAuthHeader(s, conf); auth != nil { + topicOptions = append(topicOptions, auth) + } + + subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...) + if err != nil { + return err + } + if s.Command != "" { + cmds[subscriptionID] = s.Command + } else if conf.DefaultCommand != "" { + cmds[subscriptionID] = conf.DefaultCommand + } else { + cmds[subscriptionID] = "" } - subscriptionID := cl.Subscribe(s.Topic, topicOptions...) - commands[subscriptionID] = s.Command } if topic != "" { - subscriptionID := cl.Subscribe(topic, options...) - commands[subscriptionID] = command + subscriptionID, err := cl.Subscribe(topic, options...) + if err != nil { + return err + } + cmds[subscriptionID] = command } for m := range cl.Messages { - command, ok := commands[m.SubscriptionID] + cmd, ok := cmds[m.SubscriptionID] if !ok { continue } - printMessageOrRunCommand(c, m, command) + log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw) + printMessageOrRunCommand(c, m, cmd) + } + return nil +} + +func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption { + // if an explicit empty token or empty user:pass is given, exit without auth + if (s.Token != nil && *s.Token == "") || (s.User != nil && *s.User == "" && s.Password != nil && *s.Password == "") { + return client.WithEmptyAuth() + } + + // check for subscription token then subscription user:pass + if s.Token != nil && *s.Token != "" { + return client.WithBearerAuth(*s.Token) + } + if s.User != nil && *s.User != "" && s.Password != nil { + return client.WithBasicAuth(*s.User, *s.Password) + } + + // if no subscription token nor subscription user:pass, check for default token then default user:pass + if conf.DefaultToken != "" { + return client.WithBearerAuth(conf.DefaultToken) + } + if conf.DefaultUser != "" && conf.DefaultPassword != nil { + return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword) } return nil } @@ -186,27 +252,27 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) if command != "" { runCommand(c, command, m) } else { + log.Debug("%s Printing raw message", logMessagePrefix(m)) fmt.Fprintln(c.App.Writer, m.Raw) } } func runCommand(c *cli.Context, command string, m *client.Message) { if err := runCommandInternal(c, command, m); err != nil { - fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error()) + log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error()) } } -func runCommandInternal(c *cli.Context, command string, m *client.Message) error { - scriptFile, err := createTmpScript(command) - if err != nil { +func runCommandInternal(c *cli.Context, script string, m *client.Message) error { + scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) + log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile) + script = scriptHeader + script + if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { return err } defer os.Remove(scriptFile) - verbose := c.Bool("verbose") - if verbose { - log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw) - } - cmd := exec.Command("sh", "-c", scriptFile) + log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile) + cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) cmd.Stdin = c.App.Reader cmd.Stdout = c.App.Writer cmd.Stderr = c.App.ErrWriter @@ -214,17 +280,8 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error return cmd.Run() } -func createTmpScript(command string) (string, error) { - scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10)) - script := fmt.Sprintf("#!/bin/sh\n%s", command) - if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { - return "", err - } - return scriptFile, nil -} - func envVars(m *client.Message) []string { - env := os.Environ() + env := make([]string, 0) env = append(env, envVar(m.ID, "NTFY_ID", "id")...) env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) @@ -233,7 +290,11 @@ func envVars(m *client.Message) []string { env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) - return env + sort.Strings(env) + if log.IsTrace() { + log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n")) + } + return append(os.Environ(), env...) } func envVar(value string, vars ...string) []string { @@ -249,13 +310,30 @@ func loadConfig(c *cli.Context) (*client.Config, error) { if filename != "" { return client.LoadConfig(filename) } - u, _ := user.Current() - configFile := defaultClientRootConfigFile - if u.Uid != "0" { - configFile = util.ExpandHome(defaultClientUserConfigFile) - } + configFile := defaultClientConfigFile() if s, _ := os.Stat(configFile); s != nil { return client.LoadConfig(configFile) } return client.NewConfig(), nil } + +//lint:ignore U1000 Conditionally used in different builds +func defaultClientConfigFileUnix() string { + u, _ := user.Current() + configFile := clientRootConfigFileUnixAbsolute + if u.Uid != "0" { + homeDir, _ := os.UserConfigDir() + return filepath.Join(homeDir, clientUserConfigFileUnixRelative) + } + return configFile +} + +//lint:ignore U1000 Conditionally used in different builds +func defaultClientConfigFileWindows() string { + homeDir, _ := os.UserConfigDir() + return filepath.Join(homeDir, clientUserConfigFileWindowsRelative) +} + +func logMessagePrefix(m *client.Message) string { + return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) +} diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go new file mode 100644 index 00000000..0372a79f --- /dev/null +++ b/cmd/subscribe_darwin.go @@ -0,0 +1,16 @@ +package cmd + +const ( + scriptExt = "sh" + scriptHeader = "#!/bin/sh\n" + clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), +or "~/Library/Application Support/ntfy/client.yml" for all other users.` +) + +var ( + scriptLauncher = []string{"sh", "-c"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileUnix() +} diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go new file mode 100644 index 00000000..08dbbf5d --- /dev/null +++ b/cmd/subscribe_test.go @@ -0,0 +1,417 @@ +package cmd + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: fake +default-password: password +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN0123456789FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) +} + +func TestCLI_Subscribe_Default_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Override_Default_UserPass_With_Empty_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic + user: "" + password: "" +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Override_Default_Token_With_Empty_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic + token: "" +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go new file mode 100644 index 00000000..8b91fed9 --- /dev/null +++ b/cmd/subscribe_unix.go @@ -0,0 +1,18 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package cmd + +const ( + scriptExt = "sh" + scriptHeader = "#!/bin/sh\n" + clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), +or ~/.config/ntfy/client.yml for all other users.` +) + +var ( + scriptLauncher = []string{"sh", "-c"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileUnix() +} diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go new file mode 100644 index 00000000..e8f1a271 --- /dev/null +++ b/cmd/subscribe_windows.go @@ -0,0 +1,15 @@ +package cmd + +const ( + scriptExt = "bat" + scriptHeader = "" + clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.` +) + +var ( + scriptLauncher = []string{"cmd.exe", "/Q", "/C"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileWindows() +} diff --git a/cmd/tier.go b/cmd/tier.go new file mode 100644 index 00000000..76e273a1 --- /dev/null +++ b/cmd/tier.go @@ -0,0 +1,374 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdTier) +} + +const ( + defaultMessageLimit = 5000 + defaultMessageExpiryDuration = "12h" + defaultEmailLimit = 20 + defaultCallLimit = 0 + defaultReservationLimit = 3 + defaultAttachmentFileSizeLimit = "15M" + defaultAttachmentTotalSizeLimit = "100M" + defaultAttachmentExpiryDuration = "6h" + defaultAttachmentBandwidthLimit = "1G" +) + +var ( + flagsTier = append([]cli.Flag{}, flagsUser...) +) + +var cmdTier = &cli.Command{ + Name: "tier", + Usage: "Manage/show tiers", + UsageText: "ntfy tier [list|add|change|remove] ...", + Flags: flagsTier, + Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Adds a new tier", + UsageText: "ntfy tier add [OPTIONS] CODE", + Action: execTierAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "name", Usage: "tier name"}, + &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, + &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, + &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, + &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, + &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, + &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, + &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, + &cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"}, + &cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"}, + &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"}, + }, + Description: `Add a new tier to the ntfy user database. + +Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or +make it possible for users to reserve topics. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier add pro # Add tier with code "pro", using the defaults + ntfy tier add \ # Add a tier with custom limits + --name="Pro" \ + --message-limit=10000 \ + --message-expiry-duration=24h \ + --email-limit=50 \ + --reservation-limit=10 \ + --attachment-file-size-limit=100M \ + --attachment-total-size-limit=1G \ + --attachment-expiry-duration=12h \ + --attachment-bandwidth-limit=5G \ + pro +`, + }, + { + Name: "change", + Aliases: []string{"ch"}, + Usage: "Change a tier", + UsageText: "ntfy tier change [OPTIONS] CODE", + Action: execTierChange, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "name", Usage: "tier name"}, + &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, + &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, + &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, + &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, + &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, + &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, + &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, + &cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"}, + &cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"}, + &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + }, + Description: `Updates a tier to change the limits. + +After updating a tier, you may have to restart the ntfy server to apply them +to all visitors. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier change --name="Pro" pro # Update the name of an existing tier + ntfy tier change \ # Update multiple limits and fields + --message-expiry-duration=24h \ + --stripe-monthly-price-id=price_1234 \ + --stripe-monthly-price-id=price_5678 \ + pro +`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a tier", + UsageText: "ntfy tier remove CODE", + Action: execTierDel, + Description: `Remove a tier from the ntfy user database. + +You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier" +to remove or switch their tier first. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Example: + ntfy tier del pro +`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of tiers", + Action: execTierList, + Description: `Shows a list of all configured tiers. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. +`, + }, + }, + Description: `Manage tiers of the ntfy server. + +The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used +to grant users higher limits, such as daily message limits, attachment size, or make it +possible for users to reserve topics. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier add pro # Add tier with code "pro", using the defaults + ntfy tier change --name="Pro" pro # Update the name of an existing tier + ntfy tier del pro # Delete an existing tier +`, +} + +func execTierAdd(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier add --help' for help") + } else if !user.AllowedTier(code) { + return errors.New("tier code must consist only of numbers and letters") + } else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" { + return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") + } else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" { + return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if tier, _ := manager.Tier(code); tier != nil { + if c.Bool("ignore-exists") { + fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code) + return nil + } + return fmt.Errorf("tier %s already exists", code) + } + name := c.String("name") + if name == "" { + name = code + } + messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration")) + if err != nil { + return err + } + attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit")) + if err != nil { + return err + } + attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit")) + if err != nil { + return err + } + attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit")) + if err != nil { + return err + } + attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration")) + if err != nil { + return err + } + tier := &user.Tier{ + ID: "", // Generated + Code: code, + Name: name, + MessageLimit: c.Int64("message-limit"), + MessageExpiryDuration: messageExpiryDuration, + EmailLimit: c.Int64("email-limit"), + CallLimit: c.Int64("call-limit"), + ReservationLimit: c.Int64("reservation-limit"), + AttachmentFileSizeLimit: attachmentFileSizeLimit, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit, + AttachmentExpiryDuration: attachmentExpiryDuration, + AttachmentBandwidthLimit: attachmentBandwidthLimit, + StripeMonthlyPriceID: c.String("stripe-monthly-price-id"), + StripeYearlyPriceID: c.String("stripe-yearly-price-id"), + } + if err := manager.AddTier(tier); err != nil { + return err + } + tier, err = manager.Tier(code) + if err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier added\n\n") + printTier(c, tier) + return nil +} + +func execTierChange(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier change --help' for help") + } else if !user.AllowedTier(code) { + return errors.New("tier code must consist only of numbers and letters") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + tier, err := manager.Tier(code) + if err == user.ErrTierNotFound { + return fmt.Errorf("tier %s does not exist", code) + } else if err != nil { + return err + } + if c.IsSet("name") { + tier.Name = c.String("name") + } + if c.IsSet("message-limit") { + tier.MessageLimit = c.Int64("message-limit") + } + if c.IsSet("message-expiry-duration") { + tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration")) + if err != nil { + return err + } + } + if c.IsSet("email-limit") { + tier.EmailLimit = c.Int64("email-limit") + } + if c.IsSet("call-limit") { + tier.CallLimit = c.Int64("call-limit") + } + if c.IsSet("reservation-limit") { + tier.ReservationLimit = c.Int64("reservation-limit") + } + if c.IsSet("attachment-file-size-limit") { + tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit")) + if err != nil { + return err + } + } + if c.IsSet("attachment-total-size-limit") { + tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit")) + if err != nil { + return err + } + } + if c.IsSet("attachment-expiry-duration") { + tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration")) + if err != nil { + return err + } + } + if c.IsSet("attachment-bandwidth-limit") { + tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit")) + if err != nil { + return err + } + } + if c.IsSet("stripe-monthly-price-id") { + tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id") + } + if c.IsSet("stripe-yearly-price-id") { + tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id") + } + if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" { + return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") + } else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" { + return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") + } + if err := manager.UpdateTier(tier); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n") + printTier(c, tier) + return nil +} + +func execTierDel(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier del --help' for help") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.Tier(code); err == user.ErrTierNotFound { + return fmt.Errorf("tier %s does not exist", code) + } + if err := manager.RemoveTier(code); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code) + return nil +} + +func execTierList(c *cli.Context) error { + manager, err := createUserManager(c) + if err != nil { + return err + } + tiers, err := manager.Tiers() + if err != nil { + return err + } + for _, tier := range tiers { + printTier(c, tier) + } + return nil +} + +func printTier(c *cli.Context, tier *user.Tier) { + prices := "(none)" + if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { + prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) + } + fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) + fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) + fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) + fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) + fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) + fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) + fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) + fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) + fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) +} diff --git a/cmd/tier_test.go b/cmd/tier_test.go new file mode 100644 index 00000000..b896d15f --- /dev/null +++ b/cmd/tier_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "testing" +) + +func TestCLI_Tier_AddListChangeDelete(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, _, _, stderr := newTestApp() + require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) + require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_") + + err := runTierCommand(app, conf, "add", "pro") + require.NotNil(t, err) + require.Equal(t, "tier pro already exists", err.Error()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "list")) + require.Contains(t, stderr.String(), "tier pro (id: ti_") + require.Contains(t, stderr.String(), "- Name: Pro") + require.Contains(t, stderr.String(), "- Message limit: 1234") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "change", + "--message-limit=999", + "--message-expiry-duration=2d", + "--email-limit=91", + "--reservation-limit=98", + "--attachment-file-size-limit=100m", + "--attachment-expiry-duration=1d", + "--attachment-total-size-limit=10G", + "--attachment-bandwidth-limit=100G", + "--stripe-monthly-price-id=price_991", + "--stripe-yearly-price-id=price_992", + "pro", + )) + require.Contains(t, stderr.String(), "- Message limit: 999") + require.Contains(t, stderr.String(), "- Message expiry duration: 48h") + require.Contains(t, stderr.String(), "- Email limit: 91") + require.Contains(t, stderr.String(), "- Reservation limit: 98") + require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") + require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") + require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") + require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "remove", "pro")) + require.Contains(t, stderr.String(), "tier pro removed") +} + +func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "tier", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + "--auth-default-access=" + conf.AuthDefault.String(), + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 00000000..4fe7e541 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,210 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "net/netip" + "time" +) + +func init() { + commands = append(commands, cmdToken) +} + +var flagsToken = append([]cli.Flag{}, flagsUser...) + +var cmdToken = &cli.Command{ + Name: "token", + Usage: "Create, list or delete user tokens", + UsageText: "ntfy token [list|add|remove] ...", + Flags: flagsToken, + Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Create a new token", + UsageText: "ntfy token add [--expires=] [--label=..] USERNAME", + Action: execTokenAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"}, + &cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"}, + }, + Description: `Create a new user access token. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday + ntfy token add -l backups phil # Create token for user phil with label "backups"`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a token", + UsageText: "ntfy token remove USERNAME TOKEN", + Action: execTokenDel, + Description: `Remove a token from the ntfy user database. + +Example: + ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of tokens", + Action: execTokenList, + Description: `Shows a list of all tokens. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined.`, + }, + }, + Description: `Manage access tokens for individual users. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy token list # Shows list of tokens for all users + ntfy token list phil # Shows list of tokens for user phil + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token remove phil tk_th2srHVlxr... # Delete token`, +} + +func execTokenAdd(c *cli.Context) error { + username := c.Args().Get(0) + expiresStr := c.String("expires") + label := c.String("label") + if username == "" { + return errors.New("username expected, type 'ntfy token add --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + expires := time.Unix(0, 0) + if expiresStr != "" { + var err error + expires, err = util.ParseFutureTime(expiresStr, time.Now()) + if err != nil { + return err + } + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + if err != nil { + return err + } + if expires.Unix() == 0 { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) + } else { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) + } + return nil +} + +func execTokenDel(c *cli.Context) error { + username, token := c.Args().Get(0), c.Args().Get(1) + if username == "" || token == "" { + return errors.New("username and token expected, type 'ntfy token remove --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + if err := manager.RemoveToken(u.ID, token); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) + return nil +} + +func execTokenList(c *cli.Context) error { + username := c.Args().Get(0) + if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + var users []*user.User + if username != "" { + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + users = append(users, u) + } else { + users, err = manager.Users() + if err != nil { + return err + } + } + usersWithTokens := 0 + for _, u := range users { + tokens, err := manager.Tokens(u.ID) + if err != nil { + return err + } else if len(tokens) == 0 && username != "" { + fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) + return nil + } else if len(tokens) == 0 { + continue + } + usersWithTokens++ + fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) + for _, t := range tokens { + var label, expires string + if t.Label != "" { + label = fmt.Sprintf(" (%s)", t.Label) + } + if t.Expires.Unix() == 0 { + expires = "never expires" + } else { + expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) + } + fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + } + } + if usersWithTokens == 0 { + fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n") + } + return nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go new file mode 100644 index 00000000..b5f0c0b4 --- /dev/null +++ b/cmd/token_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "regexp" + "testing" +) + +func TestCLI_Token_AddListRemove(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "add", "phil")) + require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "list", "phil")) + require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String()) + re := regexp.MustCompile(`tk_\w+`) + token := re.FindString(stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) + require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "list")) + require.Equal(t, "no users with tokens\n", stderr.String()) +} + +func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "token", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/user.go b/cmd/user.go index 1057ba48..21fe21af 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -1,33 +1,52 @@ +//go:build !noserver + package cmd import ( "crypto/subtle" "errors" "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "os" + "strings" + + "git.zio.sh/astra/ntfy/v2/util" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" - "strings" ) -var flagsUser = userCommandFlags() +const ( + tierReset = "-" +) + +func init() { + commands = append(commands, cmdUser) +} + +var flagsUser = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), +) + var cmdUser = &cli.Command{ Name: "user", Usage: "Manage/show users", UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", Flags: flagsUser, - Before: initConfigFileInputSource("config", flagsUser), + Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, Subcommands: []*cli.Command{ { Name: "add", Aliases: []string{"a"}, Usage: "Adds a new user", - UsageText: "ntfy user add [--role=admin|user] USERNAME", + UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME", Action: execUserAdd, Flags: []cli.Flag{ - &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"}, + &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"}, + &cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"}, }, Description: `Add a new user to the ntfy user database. @@ -36,8 +55,12 @@ granted otherwise by the auth-default-access setting). An admin user has read an topics. Examples: - ntfy user add phil # Add regular user phil - ntfy user add --role=admin phil # Add admin user phil + ntfy user add phil # Add regular user phil + ntfy user add --role=admin phil # Add admin user phil + NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts) + +You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if +you are creating users via scripts. `, }, { @@ -56,7 +79,7 @@ Example: Name: "change-pass", Aliases: []string{"chp"}, Usage: "Changes a user's password", - UsageText: "ntfy user change-pass USERNAME", + UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME", Action: execUserChangePass, Description: `Change the password for the given user. @@ -64,7 +87,12 @@ The new password will be read from STDIN, and it'll be confirmed by typing it twice. Example: - ntfy user change-pass phil + ntfy user change-pass phil + NTFY_PASSWORD=.. ntfy user change-pass phil + +You may set the NTFY_PASSWORD environment variable to pass the new password. This is +useful if you are updating users via scripts. + `, }, { @@ -87,6 +115,22 @@ user are removed, since they are no longer necessary. Example: ntfy user change-role phil admin # Make user phil an admin ntfy user change-role phil user # Remove admin role from user phil +`, + }, + { + Name: "change-tier", + Aliases: []string{"cht"}, + Usage: "Changes the tier of a user", + UsageText: "ntfy user change-tier USERNAME (TIER|-)", + Action: execUserChangeTier, + Description: `Change the tier for the given user. + +This command can be used to change the tier of a user. Tiers define usage limits, such +as messages per day, attachment file sizes, etc. + +Example: + ntfy user change-tier phil pro # Change tier to "pro" for user "phil" + ntfy user change-tier phil - # Remove tier from user "phil" entirely `, }, { @@ -96,52 +140,66 @@ Example: Action: execUserList, Description: `Shows a list of all configured users, including the everyone ('*') user. -This is a server-only command. It directly reads from the user.db as defined in the server config -file server.yml. The command only works if 'auth-file' is properly defined. - This command is an alias to calling 'ntfy access' (display access control list). + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. `, }, }, Description: `Manage users of the ntfy server. +The command allows you to add/remove/change users in the ntfy user database, as well as change +passwords or roles. + This is a server-only command. It directly manages the user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined. Please also refer to the related command 'ntfy access'. -The command allows you to add/remove/change users in the ntfy user database, as well as change -passwords or roles. - Examples: - ntfy user list # Shows list of users (alias: 'ntfy access') - ntfy user add phil # Add regular user phil - ntfy user add --role=admin phil # Add admin user phil - ntfy user del phil # Delete user phil - ntfy user change-pass phil # Change password for user phil - ntfy user change-role phil admin # Make user phil an admin + ntfy user list # Shows list of users (alias: 'ntfy access') + ntfy user add phil # Add regular user phil + NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts) + ntfy user add --role=admin phil # Add admin user phil + ntfy user del phil # Delete user phil + ntfy user change-pass phil # Change password for user phil + NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts) + ntfy user change-role phil admin # Make user phil an admin + +For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment +variable to pass the new password. This is useful if you are creating/updating users via scripts. `, } func execUserAdd(c *cli.Context) error { username := c.Args().Get(0) - role := auth.Role(c.String("role")) + role := user.Role(c.String("role")) + password := os.Getenv("NTFY_PASSWORD") if username == "" { return errors.New("username expected, type 'ntfy user add --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") - } else if !auth.AllowedRole(role) { + } else if !user.AllowedRole(role) { return errors.New("role must be either 'user' or 'admin'") } - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } if user, _ := manager.User(username); user != nil { + if c.Bool("ignore-exists") { + fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username) + return nil + } return fmt.Errorf("user %s already exists", username) } - password, err := readPasswordAndConfirm(c) - if err != nil { - return err + if password == "" { + p, err := readPasswordAndConfirm(c) + if err != nil { + return err + } + + password = p } if err := manager.AddUser(username, password, role); err != nil { return err @@ -154,14 +212,14 @@ func execUserDel(c *cli.Context) error { username := c.Args().Get(0) if username == "" { return errors.New("username expected, type 'ntfy user del --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } - if _, err := manager.User(username); err == auth.ErrNotFound { + if _, err := manager.User(username); err == user.ErrUserNotFound { return fmt.Errorf("user %s does not exist", username) } if err := manager.RemoveUser(username); err != nil { @@ -173,21 +231,24 @@ func execUserDel(c *cli.Context) error { func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) + password := os.Getenv("NTFY_PASSWORD") if username == "" { return errors.New("username expected, type 'ntfy user change-pass --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } - if _, err := manager.User(username); err == auth.ErrNotFound { + if _, err := manager.User(username); err == user.ErrUserNotFound { return fmt.Errorf("user %s does not exist", username) } - password, err := readPasswordAndConfirm(c) - if err != nil { - return err + if password == "" { + password, err = readPasswordAndConfirm(c) + if err != nil { + return err + } } if err := manager.ChangePassword(username, password); err != nil { return err @@ -198,17 +259,17 @@ func execUserChangePass(c *cli.Context) error { func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) - role := auth.Role(c.Args().Get(1)) - if username == "" || !auth.AllowedRole(role) { + role := user.Role(c.Args().Get(1)) + if username == "" || !user.AllowedRole(role) { return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } - if _, err := manager.User(username); err == auth.ErrNotFound { + if _, err := manager.User(username); err == user.ErrUserNotFound { return fmt.Errorf("user %s does not exist", username) } if err := manager.ChangeRole(username, role); err != nil { @@ -218,8 +279,39 @@ func execUserChangeRole(c *cli.Context) error { return nil } +func execUserChangeTier(c *cli.Context) error { + username := c.Args().Get(0) + tier := c.Args().Get(1) + if username == "" { + return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help") + } else if !user.AllowedTier(tier) && tier != tierReset { + return errors.New("invalid tier, must be tier code, or - to reset") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.User(username); err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } + if tier == tierReset { + if err := manager.ResetTier(username); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username) + } else { + if err := manager.ChangeTier(username, tier); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier) + } + return nil +} + func execUserList(c *cli.Context) error { - manager, err := createAuthManager(c) + manager, err := createUserManager(c) if err != nil { return err } @@ -230,19 +322,20 @@ func execUserList(c *cli.Context) error { return showUsers(c, manager, users) } -func createAuthManager(c *cli.Context) (auth.Manager, error) { +func createUserManager(c *cli.Context) (*user.Manager, error) { authFile := c.String("auth-file") + authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") if authFile == "" { return nil, errors.New("option auth-file not set; auth is unconfigured for this server") } else if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") - } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { - return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'") } - authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" - authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" - return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite) + authDefault, err := user.ParsePermission(authDefaultAccess) + if err != nil { + return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") + } + return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) } func readPasswordAndConfirm(c *cli.Context) (string, error) { @@ -262,11 +355,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) { } return string(password), nil } - -func userCommandFlags() []cli.Flag { - return []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - } -} diff --git a/cmd/user_test.go b/cmd/user_test.go index 666cb422..361a4288 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -1,10 +1,12 @@ package cmd import ( + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/user" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "heckel.io/ntfy/server" - "heckel.io/ntfy/test" + "os" "path/filepath" "testing" ) @@ -112,10 +114,12 @@ func TestCLI_User_Delete(t *testing.T) { } func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { + configFile := filepath.Join(t.TempDir(), "server-dummy.yml") + require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml conf = server.NewConfig() + conf.File = configFile conf.AuthFile = filepath.Join(t.TempDir(), "user.db") - conf.AuthDefaultRead = false - conf.AuthDefaultWrite = false + conf.AuthDefault = user.PermissionDenyAll s, port = test.StartServerWithConfig(t, conf) return } @@ -123,23 +127,11 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, func runUserCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", + "--log-level=ERROR", "user", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, - "--auth-default-access=" + confToDefaultAccess(conf), + "--auth-default-access=" + conf.AuthDefault.String(), } return app.Run(append(userArgs, args...)) } - -func confToDefaultAccess(conf *server.Config) string { - var defaultAccess string - if conf.AuthDefaultRead && conf.AuthDefaultWrite { - defaultAccess = "read-write" - } else if conf.AuthDefaultRead && !conf.AuthDefaultWrite { - defaultAccess = "read-only" - } else if !conf.AuthDefaultRead && conf.AuthDefaultWrite { - defaultAccess = "write-only" - } else if !conf.AuthDefaultRead && !conf.AuthDefaultWrite { - defaultAccess = "deny-all" - } - return defaultAccess -} diff --git a/cmd/webpush.go b/cmd/webpush.go new file mode 100644 index 00000000..ec66f083 --- /dev/null +++ b/cmd/webpush.go @@ -0,0 +1,48 @@ +//go:build !noserver + +package cmd + +import ( + "fmt" + + "github.com/SherClockHolmes/webpush-go" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdWebPush) +} + +var cmdWebPush = &cli.Command{ + Name: "webpush", + Usage: "Generate keys, in the future manage web push subscriptions", + UsageText: "ntfy webpush [keys]", + Category: categoryServer, + + Subcommands: []*cli.Command{ + { + Action: generateWebPushKeys, + Name: "keys", + Usage: "Generate VAPID keys to enable browser background push notifications", + UsageText: "ntfy webpush keys", + Category: categoryServer, + }, + }, +} + +func generateWebPushKeys(c *cli.Context) error { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: + +web-push-public-key: %s +web-push-private-key: %s +web-push-file: /var/cache/ntfy/webpush.db # or similar +web-push-email-address: + +See https://ntfy.sh/docs/config/#web-push for details. +`, publicKey, privateKey) + return err +} diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go new file mode 100644 index 00000000..e2565214 --- /dev/null +++ b/cmd/webpush_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "testing" + + "git.zio.sh/astra/ntfy/v2/server" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +func TestCLI_WebPush_GenerateKeys(t *testing.T) { + app, _, _, stderr := newTestApp() + require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) + require.Contains(t, stderr.String(), "Web Push keys generated.") +} + +func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { + webPushArgs := []string{ + "ntfy", + "--log-level=ERROR", + "webpush", + } + return app.Run(append(webPushArgs, args...)) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d39492e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "2.1" +services: + ntfy: + image: binwiederhier/ntfy + container_name: ntfy + command: + - serve + environment: + - TZ=UTC # optional: Change to your desired timezone + user: UID:GID # optional: Set custom user/group or uid/gid + volumes: + - /var/cache/ntfy:/var/cache/ntfy + - /etc/ntfy:/etc/ntfy + ports: + - 80:80 + restart: unless-stopped + diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html new file mode 100644 index 00000000..52483ebd --- /dev/null +++ b/docs/_overrides/main.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block announce %} + + +If you like ntfy, please consider sponsoring me via GitHub Sponsors +or Liberapay + + +, or subscribing to ntfy Pro. + +{% endblock %} diff --git a/docs/config.md b/docs/config.md index 448efddf..2662a537 100644 --- a/docs/config.md +++ b/docs/config.md @@ -44,6 +44,14 @@ Here are a few working sample configs: attachment-cache-dir: "/var/cache/ntfy/attachments" ``` +=== "server.yml (behind proxy, with cache + attachments)" + ``` yaml + base-url: "http://ntfy.example.com" + listen-http: ":2586" + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + === "server.yml (ntfy.sh config)" ``` yaml # All the things: Behind a proxy, Firebase, cache, attachments, @@ -161,6 +169,7 @@ ntfy user add --role=admin phil # Add admin user phil ntfy user del phil # Delete user phil ntfy user change-pass phil # Change password for user phil ntfy user change-role phil admin # Make user phil an admin +ntfy user change-tier phil pro # Change phil's tier to "pro" ``` ### Access control list (ACL) @@ -222,12 +231,45 @@ User `ben` has three topic-specific entries. He can read, but not write to topic to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. +### Access tokens +In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful +to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may +want to use a dedicated token to publish from your backup host, and one from your home automation system. + +!!! info + As of today, access tokens grant users **full access to the user account**. Aside from changing the password, + and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, + but not yet implemented. + +The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire +automatically (or never expire). Each user can have up to 20 tokens (hardcoded). + +**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): +``` +ntfy token list # Shows list of tokens for all users +ntfy token list phil # Shows list of tokens for user phil +ntfy token add phil # Create token for user phil which never expires +ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days +ntfy token remove phil tk_th2sxr... # Delete token +``` + +**Creating an access token:** +``` +$ ntfy token add --expires=30d --label="backups" phil +$ ntfy token list +user phil +- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST +``` + +Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or +subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). + ### Example: Private instance The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: === "/etc/ntfy/server.yml" ``` yaml - auth-file "/var/lib/ntfy/user.db" + auth-file: "/var/lib/ntfy/user.db" auth-default-access: "deny-all" ``` @@ -309,6 +351,25 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an ])); ``` +### Example: UnifiedPush +[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) +has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages. +The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the +**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details. + +To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either +allow anonymous write access for the entire prefix or explicitly per topic: + +=== "Prefix" + ``` + $ ntfy access '*' 'up*' write-only + ``` + +=== "Explicitly" + ``` + $ ntfy access '*' upYzMtZGZiYTY5 write-only + ``` + ## E-mail notifications To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured, you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. @@ -405,6 +466,31 @@ $ dig A mx1.ntfy.sh +short 3.139.215.220 ``` +### Local-only email +If you want to send emails from an internal service on the same network as your ntfy instance, you do not need to +worry about DNS records at all. Define a port for the SMTP server and pick an SMTP server domain (can be +anything). + +=== "/etc/ntfy/server.yml" + ``` yaml + smtp-server-listen: ":25" + smtp-server-domain: "example.com" + smtp-server-addr-prefix: "ntfy-" # optional + ``` + +Then, in the email settings of your internal service, set the SMTP server address to the IP address of your +ntfy instance. Set the port to the value you defined in `smtp-server-listen`. Leave any username and password +fields empty. In the "From" address, pick anything (e.g., "alerts@ntfy.sh"); the value doesn't matter. +In the "To" address, put in an email address that follows this pattern: `[topic]@[smtp-server-domain]` (or +`[smtp-server-addr-prefix][topic]@[smtp-server-domain]` if you set `smtp-server-addr-prefix`). + +So if you used `example.com` as the SMTP server domain, and you want to send a message to the `email-alerts` +topic, set the "To" address to `email-alerts@example.com`. If the topic has access restrictions, you will need +to include an access token in the "To" address, such as `email-alerts+tk_AbC123dEf456@example.com`. + +If the internal service lets you use define an email "Subject", it will become the title of the notification. +The body of the email will become the message of the notification. + ## Behind a proxy (TLS, etc.) !!! warning If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are @@ -441,8 +527,16 @@ by forwarding the `Connection` and `Upgrade` headers accordingly. In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic or the root domain: -=== "nginx (/etc/nginx/sites-*/ntfy)" +=== "nginx (convenient)" ``` + # /etc/nginx/sites-*/ntfy + # + # This config allows insecure HTTP POST/PUT requests against topics to allow a short curl syntax (without -L + # and "https://" prefix). It also disables output buffering, which has worked well for the ntfy.sh server. + # + # This is pretty much how ntfy.sh is configured. To see the exact configuration, + # see https://github.com/binwiederhier/ntfy-ansible/ + server { listen 80; server_name ntfy.sh; @@ -477,19 +571,22 @@ or the root domain: proxy_send_timeout 3m; proxy_read_timeout 3m; - client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml + client_max_body_size 0; # Stream request body to backend } } server { - listen 443 ssl; + listen 443 ssl http2; server_name ntfy.sh; - ssl_session_cache builtin:1000 shared:SSL:10m; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4; - ssl_prefer_server_ciphers on; - + # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem; @@ -510,18 +607,83 @@ or the root domain: proxy_send_timeout 3m; proxy_read_timeout 3m; - client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml + client_max_body_size 0; # Stream request body to backend } } ``` -=== "Apache2 (/etc/apache2/sites-*/ntfy.conf)" +=== "nginx (more secure)" ``` + # /etc/nginx/sites-*/ntfy + # + # This config requires the use of the -L flag in curl to redirect to HTTPS, and it keeps nginx output buffering + # enabled. While recommended, I have had issues with that in the past. + + server { + listen 80; + server_name ntfy.sh; + + location / { + return 302 https://$http_host$request_uri$is_args$query_string; + + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + + server { + listen 443 ssl http2; + server_name ntfy.sh; + + # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + ``` + +=== "Apache2" + ``` + # /etc/apache2/sites-*/ntfy.conf + ServerName ntfy.sh - # Proxy connections to ntfy (requires "a2enmod proxy") - ProxyPass / http://127.0.0.1:2586/ + # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ SetEnv proxy-nokeepalive 1 @@ -529,19 +691,13 @@ or the root domain: # Higher than the max message size of 4096 bytes LimitRequestBody 102400 - - # Enable mod_rewrite (requires "a2enmod rewrite") - RewriteEngine on - - # WebSockets support (requires "a2enmod rewrite proxy_wstunnel") - RewriteCond %{HTTP:Upgrade} websocket [NC] - RewriteCond %{HTTP:Connection} upgrade [NC] - RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want - # it to work with curl without the annoying https:// prefix - RewriteCond %{REQUEST_METHOD} GET - RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L] + # it to work with curl without the annoying https:// prefix (requires "a2enmod alias") + + RedirectMatch permanent "^/([-_A-Za-z0-9]{0,64})$" "https://%{SERVER_NAME}/$1" + + @@ -552,8 +708,8 @@ or the root domain: SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf - # Proxy connections to ntfy (requires "a2enmod proxy") - ProxyPass / http://127.0.0.1:2586/ + # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ SetEnv proxy-nokeepalive 1 @@ -561,14 +717,7 @@ or the root domain: # Higher than the max message size of 4096 bytes LimitRequestBody 102400 - - # Enable mod_rewrite (requires "a2enmod rewrite") - RewriteEngine on - - # WebSockets support (requires "a2enmod rewrite proxy_wstunnel") - RewriteCond %{HTTP:Upgrade} websocket [NC] - RewriteCond %{HTTP:Connection} upgrade [NC] - RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] + ``` @@ -618,6 +767,182 @@ Example: firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" ``` +## iOS instant notifications +Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant +push notifications without a central server. + +To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request` +messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app), +which will then forward it to Firebase/APNS. + +To configure it, simply set `upstream-base-url` like so: + +``` yaml +upstream-base-url: "https://ntfy.sh" +upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected +``` + +If set, all incoming messages will publish a poll request to the configured upstream server, containing +the message ID of the original message, instructing the iOS app to poll this server for the actual message contents. + +If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours, +depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though. + +In case you're curious, here's an example of the entire flow: + +- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic` +- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL) +- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a + poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server + contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic). +- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device +- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it + +Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl: + +``` +curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b +{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"} +``` + +Note that the self-hosted server literally sends the message `New message` for every message, even if your message +may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all), +it'll show `New message` as a popup. + +## Web Push +[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030)) +allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed. +When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the +user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then +forward it to the browser. + +To configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`), +a database to keep track of the browser's subscriptions, and an admin email address (you): + +- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 +- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 +- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` +- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com` +- `web-push-startup-queries` is an optional list of queries to run on startup` + +Limitations: + +- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_ + certificate is required, as service workers will not run on origins with untrusted certificates. + +- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This + is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin. + +To configure VAPID keys, first generate them: + +```sh +$ ntfy webpush keys +Web Push keys generated. +... +``` + +Then copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments: + +```yaml +web-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 +web-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890 +web-push-file: /var/cache/ntfy/webpush.db +web-push-email-address: sysadmin@example.com +``` + +The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days, +and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed), +subscriptions are also removed automatically. + +The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription +file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then. + +Changing your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and +if you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission. + +## Tiers +ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as +daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments), +tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way +to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)). + +By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file. +Once a user is associated with a tier, some limits are overridden based on the tier. + +The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers. + +**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): +``` +ntfy tier add pro # Add tier with code "pro", using the defaults +ntfy tier change --name="Pro" pro # Update the name of an existing tier +ntfy tier del starter # Delete an existing tier +ntfy user change-tier phil pro # Switch user "phil" to tier "pro" +``` + +**Creating a tier (full example):** +``` +ntfy tier add \ + --name="Pro" \ + --message-limit=10000 \ + --message-expiry-duration=24h \ + --email-limit=50 \ + --call-limit=10 \ + --reservation-limit=10 \ + --attachment-file-size-limit=100M \ + --attachment-total-size-limit=1G \ + --attachment-expiry-duration=12h \ + --attachment-bandwidth-limit=5G \ + --stripe-price-id=price_123456 \ + pro +``` + +## Payments +ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled, +users can register, login and switch plans in the web app. The web app will behave slightly differently if payments +are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags). + +!!! info + The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use + cases. + +To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key` +config options: + +* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values + enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys). +* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe. + Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks). +* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach + out with billing questions. If unset, nothing will be displayed. + +In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks) +for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points +to `https://ntfy.example.com/v1/account/billing/webhook`. + +Here's an example: + +``` yaml +stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG" +stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" +billing-contact: "phil@example.com" +``` + +## Phone calls +ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled, +users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header. +See [publishing page](publish.md#phone-calls) for more details. + +To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers +are the easiest), and then configure the following options: + +* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 +* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 +* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 +* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 + +After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), +and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. + ## Rate limiting !!! info Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. @@ -652,7 +977,15 @@ request every 5s (defined by `visitor-request-limit-replenish`) * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s. * `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate limiting; hostnames are resolved at the time the server is started. Defaults to an empty list. - + +### Message limits +By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits). +For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests +to publish messages, then that is the daily message limit. + +To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number +of messages a visitor can send in a day. This counter is reset every day at midnight (UTC). + ### Attachment limits Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant per-visitor limits: @@ -671,6 +1004,42 @@ are enabled): * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. +### Firebase limits +If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` +is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling) +on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time +of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to +the same topic**. + +In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message +is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously, +there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected. +After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor. + +If this ever happens, there will be a log message that looks something like this: +``` +WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor +``` + +### Subscriber-based rate limiting +By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment +size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits +of a topic's subscriber, instead of the limits of the publisher.** + +If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed +to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume +publishers (e.g. Matrix/Mastodon servers) are allowed to send. + +Once enabled, a client may send a `Rate-Topics: ,,...` header when subscribing to topics via +HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits +to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic. + +UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage` +response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's +`visitor-message-daily-limit`. + +To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`. + ## Tuning for scale If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. @@ -679,6 +1048,29 @@ out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/ Depending on *how you run it*, here are a few limits that are relevant: +### Message cache +By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it +syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh, +the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted. +See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details. + +In addition to that, for very high load servers (such as ntfy.sh), it may be beneficial to write messages to the cache +in batches, and asynchronously. This can be enabled with the `cache-batch-size` and `cache-batch-timeout`. If you start +seeing `database locked` messages in the logs, you should probably enable that. + +Here's how ntfy.sh has been tuned in the `server.yml` file: + +``` yaml +cache-batch-size: 25 +cache-batch-timeout: "1s" +cache-startup-queries: | + pragma journal_mode = WAL; + pragma synchronous = normal; + pragma temp_store = memory; + pragma busy_timeout = 15000; + vacuum; +``` + ### For systemd services If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the `LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it @@ -736,8 +1128,24 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a === "/etc/nginx/nginx.conf" ``` + # Rate limit all IP addresses http { - limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; + limit_req_zone $binary_remote_addr zone=one:10m rate=45r/m; + } + + # Alternatively, whitelist certain IP addresses + http { + geo $limited { + default 1; + 116.203.112.46/32 0; + 132.226.42.65/32 0; + ... + } + map $limited $limitkey { + 1 $binary_remote_addr; + 0 ""; + } + limit_req_zone $limitkey zone=one:10m rate=45r/m; } ``` @@ -766,59 +1174,199 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp] logpath = /var/log/nginx/error.log findtime = 600 - bantime = 7200 + bantime = 14400 maxretry = 10 ``` +## Health checks +A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below. +If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy. + +```json +{"healthy":true} +``` + +See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment. + +## Monitoring +If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to +create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)). + +To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated +listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are +doing, and/or secure access to the endpoint in your reverse proxy. + +- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) +- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly + enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" + +=== "server.yml (Using default port)" + ```yaml + enable-metrics: true + ``` + +=== "server.yml (Using dedicated IP/port)" + ```yaml + metrics-listen-http: "10.0.1.1:9090" + ``` + +In Prometheus, an example scrape config would look like this: + +=== "prometheus.yml" + ```yaml + scrape_configs: + - job_name: "ntfy" + static_configs: + - targets: ["10.0.1.1:9090"] + ``` + +Here's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)): + +
+ +
ntfy Grafana dashboard
+
+ +## Profiling +ntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server. +If enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://:/debug/pprof/`. +This can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option. + +## Logging & debugging +By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format. + +ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular +log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded +by calling `kill -HUP $pid` or `systemctl reload ntfy`. + +The following config options define the logging behavior: + +* `log-format` defines the output format, can be `text` (default) or `json` +* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr. +* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`. + Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes. +* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful + for debugging certain parts of the system (e.g. only the account management, or only a certain visitor). + This is an array of strings in the format: + - `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace` + - `field -> level` to match any value, e.g. `time_taken_ms -> debug` + +**Logging config (good for production use):** +``` yaml +log-level: info +log-format: json +log-file: /var/log/ntfy.log +``` + +**Temporary debugging:** +If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level` +to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message +contents. The `trace` setting will also print the message contents. + +Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`), +a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what +they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy). + +Here's an example that will output only `info` log events, except when they match either of the defined overrides: +``` yaml +log-level: info +log-level-overrides: + - "tag=manager -> trace" + - "visitor_ip=1.2.3.4 -> debug" + - "time_taken_ms -> debug" +``` + +!!! warning + The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a + performance penalty. Only use it for temporary debugging. + +You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after +editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), +or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this: + +``` +$ ntfy serve +2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO +2022/06/02 10:29:34 INFO Partially hot reloading configuration ... +2022/06/02 10:29:34 INFO Log level is TRACE +``` + ## Config options Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). -| Config option | Env variable | Format | Default | Description | -|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | -| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | -| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | -| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | -| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | -| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | -| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). | -| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | -| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | -| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | -| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | -| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | -| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | -| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | -| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | -| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | -| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | -| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | -| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | -| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | -| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | -| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | -| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | -| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | -| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) | -| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | -| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | -| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | -| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | -| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | -| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | -| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | -| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | +!!! info + All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g. + `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do + not support dashes. + +| Config option | Env variable | Format | Default | Description | +|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | +| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | +| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | +| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | +| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | +| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | +| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | +| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). | +| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | +| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | +| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#wal-for-message-cache) | +| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | +| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | +| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | +| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | +| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | +| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | +| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | +| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | +| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | +| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | +| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | +| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | +| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | +| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | +| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | +| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | +| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | +| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | +| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | +| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | +| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | +| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | +| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | +| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | +| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | +| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | +| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | +| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | +| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | +| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | +| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | +| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | +| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | +| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | +| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | +| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | +| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | +| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | +| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | +| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | +| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate | +| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate | +| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions | +| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | +| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup | The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. ## Command line options ``` -$ ntfy serve --help NAME: ntfy serve - Run the ntfy server @@ -830,51 +1378,85 @@ CATEGORY: DESCRIPTION: Run the ntfy server and listen for incoming requests - + The command will load the configuration from /etc/ntfy/server.yml. Config options can be overridden using the command line options. - + Examples: ntfy serve # Starts server in the foreground (on port 80) ntfy serve --listen-http :8080 # Starts server with alternate port OPTIONS: - --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] - --base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] - --listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] - --listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] - --listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX] - --key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] - --cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] - --firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] - --cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] - --cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] - --auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] - --auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] - --attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] - --attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] - --attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] - --attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] - --keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] - --manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] - --web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT] - --smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] - --smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] - --smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] - --smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] - --smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] - --smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] - --smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] - --global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] - --visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] - --visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] - --visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] - --visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] - --visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] - --visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] - --visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] - --visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] - --behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] - --help, -h show help (default: false) + --debug, -d enable debug logging (default: false) [$NTFY_DEBUG] + --trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE] + --no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES] + --log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL] + --log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES] + --log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT] + --log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE] + --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] + --base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] + --listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] + --listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS] + --listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX] + --listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE] + --key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] + --cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] + --firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] + --cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] + --cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] + --cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE] + --cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT] + --cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES] + --auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] + --auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES] + --auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] + --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] + --attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] + --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] + --attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] + --keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] + --manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] + --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS] + --web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT] + --enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP] + --enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN] + --enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS] + --upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL] + --upstream-access-token value, --upstream_access_token value access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN] + --smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] + --smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] + --smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] + --smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] + --smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] + --smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] + --smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] + --twilio-account value, --twilio_account value Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT] + --twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN] + --twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER] + --twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE] + --global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] + --visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] + --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] + --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] + --visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] + --visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] + --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] + --visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT] + --visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] + --visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING] + --behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] + --stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY] + --stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY] + --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT] + --enable-metrics, --enable_metrics if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS] + --metrics-listen-http value, --metrics_listen_http value ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP] + --profile-listen-http value, --profile_listen_http value ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP] + --web-push-public-key value, --web_push_public_key value public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY] + --web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY] + --web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE] + --web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS] + --web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES] + --help, -h show help ``` - diff --git a/docs/deprecations.md b/docs/deprecations.md index b94a1277..99cdeeb9 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -1,29 +1,44 @@ # Deprecation notices This page is used to list deprecation notices for ntfy. Deprecated commands and options will be -**removed after ~3 months** from the time they were deprecated. +**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated +before the behavior is changed depends on the severity of the change, and how prominent the feature is. ## Active deprecations - -### Android app: WebSockets will become the default connection protocol -> Active since 2022-03-13, behavior will change in **June 2022** - -In future versions of the Android app, instant delivery connections and connections to self-hosted servers will -be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy). - -Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely -seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to -WebSocket in June. - -### Android app: Using `since=` instead of `since=` -> Active since 2022-02-27, behavior will change in **May 2022** - -In about 3 months, the Android app will start using `since=` instead of `since=`, which means that it will -not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app. - -The `since=` endpoint will continue to work. This is merely a notice that the Android app behavior will change. +_No active deprecations_ ## Previous deprecations +### ntfy CLI: `ntfy publish --env-topic` will be removed +> Active since 2022-06-20, behavior changed with v1.30.1 + +The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the +`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag. + +=== "Before" + ``` + $ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message" + ``` + +=== "After" + ``` + $ NTFY_TOPIC=mytopic ntfy publish "this is the message" + ``` + +### Android app: WebSockets will become the default connection protocol +> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20) + +Instant delivery connections and connections to self-hosted servers in the Android app were going to switch +to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default +and add a notice banner in the Android app instead. + +### Android app: Using `since=` instead of `since=` +> Active since 2022-02-27, behavior changed with v1.14.0 + +The Android app started using `since=` instead of `since=`, which means as of Android app v1.14.0, +it will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app. + +The `since=` endpoint will continue to work. This is merely a notice that the Android app behavior will change. + ### Running server via `ntfy` (instead of `ntfy serve`) > Deprecated 2021-12-17, behavior changed with v1.10.0 diff --git a/docs/develop.md b/docs/develop.md index 6de4af2b..b090c8c5 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -16,7 +16,7 @@ server consists of three components: * **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to build the docs. -* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/) +* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/) to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`) and install all the 100,000 dependencies (*sigh*). @@ -43,6 +43,13 @@ Build related: The `web/` and `docs/` folder are the sources for web app and documentation. During the build process, the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation). +### Build/test on Gitpod +To get a quick working development environment you can use [Gitpod](https://gitpod.io), an in-browser IDE +that makes it easy to develop ntfy without having to set up a desktop IDE. For any real development, +I do suggest a proper IDE like [IntelliJ IDEA](https://www.jetbrains.com/idea/). + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/binwiederhier/ntfy) + ### Build requirements * [Go](https://go.dev/) (required for main server) @@ -58,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions. First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)): ``` shell -wget https://go.dev/dl/go1.18.linux-amd64.tar.gz -rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz +wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz +sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin go version # verifies that it worked ``` @@ -72,7 +79,7 @@ goreleaser -v # verifies that it worked Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)): ``` shell -curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash - +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs npm -v # verifies that it worked ``` @@ -85,7 +92,6 @@ sudo apt install \ gcc-arm-linux-gnueabi \ gcc-aarch64-linux-gnu \ python3-pip \ - upx \ git ``` @@ -112,15 +118,15 @@ by typing `make`: $ make Typical commands (more see below): make build - Build web app, documentation and server/client (sloowwww) - make server-amd64 - Build server/client binary (amd64, no web app or docs) - make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64) + make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs) + make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64) make web - Build the web app make docs - Build the documentation make check - Run all tests, vetting/formatting checks and linters ... ``` -If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64), +If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64), you can simply run `make build`: ``` shell @@ -157,48 +163,62 @@ $ make release-snapshot During development, you may want to be more picky and build only certain things. Here are a few examples. +### Build a Docker image only for Linux + +This is useful to test the final build with web app, docs, and server without any dependencies locally + +``` shell +$ make docker-dev +$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve +``` + ### Build the ntfy binary -To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets: +To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: ``` shell $ make -Build server & client (not release version): - make server - Build server & client (all architectures) - make server-amd64 - Build server & client (amd64 only) - make server-armv7 - Build server & client (armv7 only) - make server-arm64 - Build server & client (arm64 only) +Build server & client (using GoReleaser, not release version): + make cli - Build server & client (all architectures) + make cli-linux-amd64 - Build server & client (Linux, amd64 only) + make cli-linux-armv6 - Build server & client (Linux, armv6 only) + make cli-linux-armv7 - Build server & client (Linux, armv7 only) + make cli-linux-arm64 - Build server & client (Linux, arm64 only) + make cli-windows-amd64 - Build client (Windows, amd64 only) + make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary) ``` -So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern -system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary +So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern +system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary right away: ``` shell -$ make server-amd64 install-amd64 +$ make cli-linux-amd64 install-linux-amd64 $ ntfy serve ``` **During development of the main app, you can also just use `go run main.go`**, as long as you run -`make server-deps-static-sites`at least once and `CGO_ENABLED=1`: +`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`: ``` shell $ export CGO_ENABLED=1 -$ make server-deps-static-sites +$ make cli-deps-static-sites $ go run main.go serve 2022/03/18 08:43:55 Listening on :2586[http] ... ``` -If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*: +If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*: ``` $ go run main.go serve server/server.go:85:13: pattern docs: no matching files found ``` This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be -present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites` -target creates dummy files that ensures that you'll be able to build. +present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites` +target creates dummy files that ensure that you'll be able to build. +While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run +`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it. ### Build the web app The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app @@ -210,7 +230,7 @@ $ make web ``` This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so -that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary. +that when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary. If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser @@ -221,6 +241,41 @@ $ cd web $ npm start ``` +### Testing Web Push locally + +Reference: + +#### With the dev servers + +1. Get web push keys `go run main.go webpush keys` + +2. Run the server with web push enabled + + ```sh + go run main.go \ + --log-level debug \ + serve \ + --web-push-public-key KEY \ + --web-push-private-key KEY \ + --web-push-email-address \ + --web-push-file=/tmp/webpush.db + ``` + +3. In `web/public/config.js`: + + - Set `base_url` to `http://localhost`, This is required as web push can only be used with the server matching the `base_url`. + + - Set the `web_push_public_key` correctly. + +4. Run `npm run start` + +#### With a built package + +1. Run `make web-build` + +2. Run the server (step 2 above) + +3. Open ### Build the docs The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the documentation. As long as you have `mkdocs` installed (see above), this should work fine: @@ -282,9 +337,13 @@ Then either follow the steps for building with or without Firebase. I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will work without issues. Please give me feedback if it does/doesn't work for you. -Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml) +Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) if you're self-hosting the server. Then run: ``` +# Remove Google dependencies (FCM) +sed -i -e '/google-services/d' build.gradle +sed -i -e '/google-services/d' app/build.gradle + # To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk) ./gradlew assembleFdroidRelease @@ -301,7 +360,7 @@ To build your own version with Firebase, you must: * Create a Firebase/FCM account * Place your account file at `app/google-services.json` -* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml) +* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) * Then run: ``` # To build an unsigned .apk (app/build/outputs/apk/play/*.apk) @@ -310,3 +369,78 @@ To build your own version with Firebase, you must: # To build a bundle .aab (app/play/release/*.aab) ./gradlew bundlePlayRelease ``` + +## iOS app +Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are +strictly based off of my development on this app. There may be other versions of macOS / XCode that work. + +### Requirements +1. macOS Monterey or later +1. XCode 13.2+ +1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator) +1. Firebase account +1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license) + +### Apple setup + +!!! info + Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required + for these changes to take effect in the iOS app. + +1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add) + 1. Select "Apple Push Notifications service (APNs)" +1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**) +1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page +1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer. + +!!! warning + If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs. + +If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered +instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application +sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours, +days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly +recommended. + +### Firebase setup + +1. If you haven't already, create a Google / Firebase account +1. Visit the [Firebase console](https://console.firebase.google.com) +1. Create a new Firebase project: + 1. Enter a project name + 1. Disable Google Analytics (currently iOS app does not support analytics) +1. On the "Project settings" page, add an iOS app + 1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value) + 1. Register the app + 1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode) +1. Generate a new service account private key for the ntfy server + 1. Go to "Project settings" > "Service accounts" + 1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server + +### ntfy server +Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these +steps: + +1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder +1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/` +1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key +1. Install go: `brew install go` +1. In the ntfy repository, run `make cli-darwin-server`. + +### XCode setup + +1. Follow step 4 of [Add Firebase to your Apple project](https://firebase.google.com/docs/ios/setup) to install the + `firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging +1. Similarly, install the SQLite.swift package dependency in XCode +1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators + +### PLIST config +To have instant notifications/better notification delivery when using firebase, you will need to add the +`GoogleService-Info.plist` file to your project. Here's how to do that: + +1. In XCode, find the NTFY app target. **Not** the NSE app target. +1. Find the Asset/ folder in the project navigator +1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be + found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist" + +After that, you should be all set! diff --git a/docs/emojis.md b/docs/emojis.md index 594a6ec0..d801ae09 100644 --- a/docs/emojis.md +++ b/docs/emojis.md @@ -2,1830 +2,1830 @@ -You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically +You can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the -[tagging and emojis page](../publish/#tags-emojis). +[tagging and emojis page](publish.md#tags-emojis). - +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + -
TagEmoji
grinning😀
smiley😃
smile😄
grin😁
laughing😆
sweat_smile😅
rofl🤣
joy😂
slightly_smiling_face🙂
upside_down_face🙃
wink😉
blush😊
innocent😇
smiling_face_with_three_hearts🥰
heart_eyes😍
star_struck🤩
kissing_heart😘
kissing😗
relaxed☺️
kissing_closed_eyes😚
kissing_smiling_eyes😙
smiling_face_with_tear🥲
yum😋
stuck_out_tongue😛
stuck_out_tongue_winking_eye😜
zany_face🤪
stuck_out_tongue_closed_eyes😝
money_mouth_face🤑
hugs🤗
hand_over_mouth🤭
shushing_face🤫
thinking🤔
zipper_mouth_face🤐
raised_eyebrow🤨
neutral_face😐
expressionless😑
no_mouth😶
face_in_clouds😶‍🌫️
smirk😏
unamused😒
roll_eyes🙄
grimacing😬
face_exhaling😮‍💨
lying_face🤥
relieved😌
pensive😔
sleepy😪
drooling_face🤤
sleeping😴
mask😷
face_with_thermometer🤒
face_with_head_bandage🤕
nauseated_face🤢
vomiting_face🤮
sneezing_face🤧
hot_face🥵
cold_face🥶
woozy_face🥴
dizzy_face😵
face_with_spiral_eyes😵‍💫
exploding_head🤯
cowboy_hat_face🤠
partying_face🥳
disguised_face🥸
sunglasses😎
nerd_face🤓
monocle_face🧐
confused😕
worried😟
slightly_frowning_face🙁
frowning_face☹️
open_mouth😮
hushed😯
astonished😲
flushed😳
pleading_face🥺
frowning😦
anguished😧
fearful😨
cold_sweat😰
disappointed_relieved😥
cry😢
sob😭
scream😱
confounded😖
persevere😣
disappointed😞
sweat😓
weary😩
tired_face😫
yawning_face🥱
triumph😤
rage😡
angry😠
cursing_face🤬
smiling_imp😈
imp👿
skull💀
skull_and_crossbones☠️
hankey💩
clown_face🤡
japanese_ogre👹
japanese_goblin👺
ghost👻
alien👽
space_invader👾
robot🤖
smiley_cat😺
smile_cat😸
joy_cat😹
heart_eyes_cat😻
smirk_cat😼
kissing_cat😽
scream_cat🙀
crying_cat_face😿
pouting_cat😾
see_no_evil🙈
hear_no_evil🙉
speak_no_evil🙊
kiss💋
love_letter💌
cupid💘
gift_heart💝
sparkling_heart💖
heartpulse💗
heartbeat💓
revolving_hearts💞
two_hearts💕
heart_decoration💟
heavy_heart_exclamation❣️
broken_heart💔
heart_on_fire❤️‍🔥
mending_heart❤️‍🩹
heart❤️
orange_heart🧡
yellow_heart💛
green_heart💚
blue_heart💙
purple_heart💜
brown_heart🤎
black_heart🖤
white_heart🤍
100💯
anger💢
boom💥
dizzy💫
sweat_drops💦
dash💨
hole🕳️
bomb💣
speech_balloon💬
eye_speech_bubble👁️‍🗨️
left_speech_bubble🗨️
right_anger_bubble🗯️
thought_balloon💭
zzz💤
wave👋
raised_back_of_hand🤚
raised_hand_with_fingers_splayed🖐️
hand
vulcan_salute🖖
ok_hand👌
pinched_fingers🤌
pinching_hand🤏
v✌️
crossed_fingers🤞
love_you_gesture🤟
metal🤘
call_me_hand🤙
point_left👈
point_right👉
point_up_2👆
middle_finger🖕
point_down👇
point_up☝️
+1👍
-1👎
fist_raised
fist_oncoming👊
fist_left🤛
fist_right🤜
clap👏
raised_hands🙌
open_hands👐
palms_up_together🤲
handshake🤝
pray🙏
writing_hand✍️
nail_care💅
selfie🤳
muscle💪
mechanical_arm🦾
mechanical_leg🦿
leg🦵
foot🦶
ear👂
ear_with_hearing_aid🦻
nose👃
brain🧠
anatomical_heart🫀
lungs🫁
tooth🦷
bone🦴
eyes👀
eye👁️
tongue👅
lips👄
baby👶
child🧒
boy👦
girl👧
adult🧑
blond_haired_person👱
man👨
bearded_person🧔
man_beard🧔‍♂️
woman_beard🧔‍♀️
red_haired_man👨‍🦰
curly_haired_man👨‍🦱
white_haired_man👨‍🦳
bald_man👨‍🦲
woman👩
red_haired_woman👩‍🦰
person_red_hair🧑‍🦰
curly_haired_woman👩‍🦱
person_curly_hair🧑‍🦱
white_haired_woman👩‍🦳
person_white_hair🧑‍🦳
bald_woman👩‍🦲
person_bald🧑‍🦲
blond_haired_woman👱‍♀️
blond_haired_man👱‍♂️
older_adult🧓
older_man👴
older_woman👵
frowning_person🙍
frowning_man🙍‍♂️
frowning_woman🙍‍♀️
pouting_face🙎
pouting_man🙎‍♂️
pouting_woman🙎‍♀️
no_good🙅
no_good_man🙅‍♂️
no_good_woman🙅‍♀️
ok_person🙆
ok_man🙆‍♂️
ok_woman🙆‍♀️
tipping_hand_person💁
tipping_hand_man💁‍♂️
tipping_hand_woman💁‍♀️
raising_hand🙋
raising_hand_man🙋‍♂️
raising_hand_woman🙋‍♀️
deaf_person🧏
deaf_man🧏‍♂️
deaf_woman🧏‍♀️
bow🙇
bowing_man🙇‍♂️
bowing_woman🙇‍♀️
facepalm🤦
man_facepalming🤦‍♂️
woman_facepalming🤦‍♀️
shrug🤷
man_shrugging🤷‍♂️
woman_shrugging🤷‍♀️
health_worker🧑‍⚕️
man_health_worker👨‍⚕️
woman_health_worker👩‍⚕️
student🧑‍🎓
man_student👨‍🎓
woman_student👩‍🎓
teacher🧑‍🏫
man_teacher👨‍🏫
woman_teacher👩‍🏫
judge🧑‍⚖️
man_judge👨‍⚖️
woman_judge👩‍⚖️
farmer🧑‍🌾
man_farmer👨‍🌾
woman_farmer👩‍🌾
cook🧑‍🍳
man_cook👨‍🍳
woman_cook👩‍🍳
mechanic🧑‍🔧
man_mechanic👨‍🔧
woman_mechanic👩‍🔧
factory_worker🧑‍🏭
man_factory_worker👨‍🏭
woman_factory_worker👩‍🏭
office_worker🧑‍💼
man_office_worker👨‍💼
woman_office_worker👩‍💼
scientist🧑‍🔬
man_scientist👨‍🔬
woman_scientist👩‍🔬
technologist🧑‍💻
man_technologist👨‍💻
woman_technologist👩‍💻
singer🧑‍🎤
man_singer👨‍🎤
woman_singer👩‍🎤
artist🧑‍🎨
man_artist👨‍🎨
woman_artist👩‍🎨
pilot🧑‍✈️
man_pilot👨‍✈️
woman_pilot👩‍✈️
astronaut🧑‍🚀
man_astronaut👨‍🚀
woman_astronaut👩‍🚀
firefighter🧑‍🚒
man_firefighter👨‍🚒
woman_firefighter👩‍🚒
police_officer👮
policeman👮‍♂️
policewoman👮‍♀️
detective🕵️
male_detective🕵️‍♂️
female_detective🕵️‍♀️
guard💂
guardsman💂‍♂️
guardswoman💂‍♀️
ninja🥷
construction_worker👷
construction_worker_man👷‍♂️
construction_worker_woman👷‍♀️
prince🤴
princess👸
person_with_turban👳
man_with_turban👳‍♂️
woman_with_turban👳‍♀️
man_with_gua_pi_mao👲
woman_with_headscarf🧕
person_in_tuxedo🤵
man_in_tuxedo🤵‍♂️
woman_in_tuxedo🤵‍♀️
person_with_veil👰
man_with_veil👰‍♂️
woman_with_veil👰‍♀️
pregnant_woman🤰
breast_feeding🤱
woman_feeding_baby👩‍🍼
man_feeding_baby👨‍🍼
person_feeding_baby🧑‍🍼
angel👼
santa🎅
mrs_claus🤶
mx_claus🧑‍🎄
superhero🦸
superhero_man🦸‍♂️
superhero_woman🦸‍♀️
supervillain🦹
supervillain_man🦹‍♂️
supervillain_woman🦹‍♀️
mage🧙
mage_man🧙‍♂️
mage_woman🧙‍♀️
fairy🧚
fairy_man🧚‍♂️
fairy_woman🧚‍♀️
vampire🧛
vampire_man🧛‍♂️
vampire_woman🧛‍♀️
merperson🧜
merman🧜‍♂️
mermaid🧜‍♀️
elf🧝
elf_man🧝‍♂️
elf_woman🧝‍♀️
genie🧞
genie_man🧞‍♂️
genie_woman🧞‍♀️
zombie🧟
zombie_man🧟‍♂️
zombie_woman🧟‍♀️
massage💆
massage_man💆‍♂️
massage_woman💆‍♀️
haircut💇
haircut_man💇‍♂️
haircut_woman💇‍♀️
walking🚶
walking_man🚶‍♂️
walking_woman🚶‍♀️
standing_person🧍
standing_man🧍‍♂️
standing_woman🧍‍♀️
kneeling_person🧎
kneeling_man🧎‍♂️
kneeling_woman🧎‍♀️
person_with_probing_cane🧑‍🦯
man_with_probing_cane👨‍🦯
woman_with_probing_cane👩‍🦯
person_in_motorized_wheelchair🧑‍🦼
man_in_motorized_wheelchair👨‍🦼
woman_in_motorized_wheelchair👩‍🦼
person_in_manual_wheelchair🧑‍🦽
man_in_manual_wheelchair👨‍🦽
woman_in_manual_wheelchair👩‍🦽
runner🏃
running_man🏃‍♂️
running_woman🏃‍♀️
woman_dancing💃
man_dancing🕺
business_suit_levitating🕴️
dancers👯
dancing_men👯‍♂️
dancing_women👯‍♀️
sauna_person🧖
sauna_man🧖‍♂️
sauna_woman🧖‍♀️
climbing🧗
climbing_man🧗‍♂️
climbing_woman🧗‍♀️
person_fencing🤺
horse_racing🏇
skier⛷️
snowboarder🏂
golfing🏌️
golfing_man🏌️‍♂️
golfing_woman🏌️‍♀️
surfer🏄
surfing_man🏄‍♂️
surfing_woman🏄‍♀️
rowboat🚣
rowing_man🚣‍♂️
rowing_woman🚣‍♀️
swimmer🏊
swimming_man🏊‍♂️
swimming_woman🏊‍♀️
bouncing_ball_person⛹️
bouncing_ball_man⛹️‍♂️
bouncing_ball_woman⛹️‍♀️
weight_lifting🏋️
weight_lifting_man🏋️‍♂️
weight_lifting_woman🏋️‍♀️
bicyclist🚴
biking_man🚴‍♂️
biking_woman🚴‍♀️
mountain_bicyclist🚵
mountain_biking_man🚵‍♂️
mountain_biking_woman🚵‍♀️
cartwheeling🤸
man_cartwheeling🤸‍♂️
woman_cartwheeling🤸‍♀️
wrestling🤼
men_wrestling🤼‍♂️
women_wrestling🤼‍♀️
water_polo🤽
man_playing_water_polo🤽‍♂️
woman_playing_water_polo🤽‍♀️
handball_person🤾
man_playing_handball🤾‍♂️
woman_playing_handball🤾‍♀️
juggling_person🤹
man_juggling🤹‍♂️
woman_juggling🤹‍♀️
lotus_position🧘
lotus_position_man🧘‍♂️
lotus_position_woman🧘‍♀️
bath🛀
sleeping_bed🛌
people_holding_hands🧑‍🤝‍🧑
two_women_holding_hands👭
couple👫
two_men_holding_hands👬
couplekiss💏
couplekiss_man_woman👩‍❤️‍💋‍👨
couplekiss_man_man👨‍❤️‍💋‍👨
couplekiss_woman_woman👩‍❤️‍💋‍👩
couple_with_heart💑
couple_with_heart_woman_man👩‍❤️‍👨
couple_with_heart_man_man👨‍❤️‍👨
couple_with_heart_woman_woman👩‍❤️‍👩
family👪
family_man_woman_boy👨‍👩‍👦
family_man_woman_girl👨‍👩‍👧
family_man_woman_girl_boy👨‍👩‍👧‍👦
family_man_woman_boy_boy👨‍👩‍👦‍👦
family_man_woman_girl_girl👨‍👩‍👧‍👧
family_man_man_boy👨‍👨‍👦
family_man_man_girl👨‍👨‍👧
family_man_man_girl_boy👨‍👨‍👧‍👦
family_man_man_boy_boy👨‍👨‍👦‍👦
family_man_man_girl_girl👨‍👨‍👧‍👧
family_woman_woman_boy👩‍👩‍👦
family_woman_woman_girl👩‍👩‍👧
family_woman_woman_girl_boy👩‍👩‍👧‍👦
family_woman_woman_boy_boy👩‍👩‍👦‍👦
family_woman_woman_girl_girl👩‍👩‍👧‍👧
family_man_boy👨‍👦
family_man_boy_boy👨‍👦‍👦
family_man_girl👨‍👧
family_man_girl_boy👨‍👧‍👦
family_man_girl_girl👨‍👧‍👧
family_woman_boy👩‍👦
family_woman_boy_boy👩‍👦‍👦
family_woman_girl👩‍👧
family_woman_girl_boy👩‍👧‍👦
family_woman_girl_girl👩‍👧‍👧
speaking_head🗣️
bust_in_silhouette👤
busts_in_silhouette👥
people_hugging🫂
footprints👣
monkey_face🐵
monkey🐒
gorilla🦍
orangutan🦧
dog🐶
dog2🐕
guide_dog🦮
service_dog🐕‍🦺
poodle🐩
wolf🐺
fox_face🦊
raccoon🦝
cat🐱
cat2🐈
black_cat🐈‍⬛
lion🦁
tiger🐯
tiger2🐅
leopard🐆
horse🐴
racehorse🐎
unicorn🦄
zebra🦓
deer🦌
bison🦬
cow🐮
ox🐂
water_buffalo🐃
cow2🐄
pig🐷
pig2🐖
boar🐗
pig_nose🐽
ram🐏
sheep🐑
goat🐐
dromedary_camel🐪
camel🐫
llama🦙
giraffe🦒
elephant🐘
mammoth🦣
rhinoceros🦏
hippopotamus🦛
mouse🐭
mouse2🐁
rat🐀
hamster🐹
rabbit🐰
rabbit2🐇
chipmunk🐿️
beaver🦫
hedgehog🦔
bat🦇
bear🐻
polar_bear🐻‍❄️
koala🐨
panda_face🐼
sloth🦥
otter🦦
skunk🦨
kangaroo🦘
badger🦡
feet🐾
turkey🦃
chicken🐔
rooster🐓
hatching_chick🐣
baby_chick🐤
hatched_chick🐥
bird🐦
penguin🐧
dove🕊️
eagle🦅
duck🦆
swan🦢
owl🦉
dodo🦤
feather🪶
flamingo🦩
peacock🦚
parrot🦜
frog🐸
crocodile🐊
turtle🐢
lizard🦎
snake🐍
dragon_face🐲
dragon🐉
sauropod🦕
t-rex🦖
whale🐳
whale2🐋
dolphin🐬
seal🦭
fish🐟
tropical_fish🐠
blowfish🐡
shark🦈
octopus🐙
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
grinning😀
smiley😃
smile😄
grin😁
laughing😆
sweat_smile😅
rofl🤣
joy😂
slightly_smiling_face🙂
upside_down_face🙃
wink😉
blush😊
innocent😇
smiling_face_with_three_hearts🥰
heart_eyes😍
star_struck🤩
kissing_heart😘
kissing😗
relaxed☺️
kissing_closed_eyes😚
kissing_smiling_eyes😙
smiling_face_with_tear🥲
yum😋
stuck_out_tongue😛
stuck_out_tongue_winking_eye😜
zany_face🤪
stuck_out_tongue_closed_eyes😝
money_mouth_face🤑
hugs🤗
hand_over_mouth🤭
shushing_face🤫
thinking🤔
zipper_mouth_face🤐
raised_eyebrow🤨
neutral_face😐
expressionless😑
no_mouth😶
face_in_clouds😶‍🌫️
smirk😏
unamused😒
roll_eyes🙄
grimacing😬
face_exhaling😮‍💨
lying_face🤥
relieved😌
pensive😔
sleepy😪
drooling_face🤤
sleeping😴
mask😷
face_with_thermometer🤒
face_with_head_bandage🤕
nauseated_face🤢
vomiting_face🤮
sneezing_face🤧
hot_face🥵
cold_face🥶
woozy_face🥴
dizzy_face😵
face_with_spiral_eyes😵‍💫
exploding_head🤯
cowboy_hat_face🤠
partying_face🥳
disguised_face🥸
sunglasses😎
nerd_face🤓
monocle_face🧐
confused😕
worried😟
slightly_frowning_face🙁
frowning_face☹️
open_mouth😮
hushed😯
astonished😲
flushed😳
pleading_face🥺
frowning😦
anguished😧
fearful😨
cold_sweat😰
disappointed_relieved😥
cry😢
sob😭
scream😱
confounded😖
persevere😣
disappointed😞
sweat😓
weary😩
tired_face😫
yawning_face🥱
triumph😤
rage😡
angry😠
cursing_face🤬
smiling_imp😈
imp👿
skull💀
skull_and_crossbones☠️
hankey💩
clown_face🤡
japanese_ogre👹
japanese_goblin👺
ghost👻
alien👽
space_invader👾
robot🤖
smiley_cat😺
smile_cat😸
joy_cat😹
heart_eyes_cat😻
smirk_cat😼
kissing_cat😽
scream_cat🙀
crying_cat_face😿
pouting_cat😾
see_no_evil🙈
hear_no_evil🙉
speak_no_evil🙊
kiss💋
love_letter💌
cupid💘
gift_heart💝
sparkling_heart💖
heartpulse💗
heartbeat💓
revolving_hearts💞
two_hearts💕
heart_decoration💟
heavy_heart_exclamation❣️
broken_heart💔
heart_on_fire❤️‍🔥
mending_heart❤️‍🩹
heart❤️
orange_heart🧡
yellow_heart💛
green_heart💚
blue_heart💙
purple_heart💜
brown_heart🤎
black_heart🖤
white_heart🤍
100💯
anger💢
boom💥
dizzy💫
sweat_drops💦
dash💨
hole🕳️
bomb💣
speech_balloon💬
eye_speech_bubble👁️‍🗨️
left_speech_bubble🗨️
right_anger_bubble🗯️
thought_balloon💭
zzz💤
wave👋
raised_back_of_hand🤚
raised_hand_with_fingers_splayed🖐️
hand
vulcan_salute🖖
ok_hand👌
pinched_fingers🤌
pinching_hand🤏
v✌️
crossed_fingers🤞
love_you_gesture🤟
metal🤘
call_me_hand🤙
point_left👈
point_right👉
point_up_2👆
middle_finger🖕
point_down👇
point_up☝️
+1👍
-1👎
fist_raised
fist_oncoming👊
fist_left🤛
fist_right🤜
clap👏
raised_hands🙌
open_hands👐
palms_up_together🤲
handshake🤝
pray🙏
writing_hand✍️
nail_care💅
selfie🤳
muscle💪
mechanical_arm🦾
mechanical_leg🦿
leg🦵
foot🦶
ear👂
ear_with_hearing_aid🦻
nose👃
brain🧠
anatomical_heart🫀
lungs🫁
tooth🦷
bone🦴
eyes👀
eye👁️
tongue👅
lips👄
baby👶
child🧒
boy👦
girl👧
adult🧑
blond_haired_person👱
man👨
bearded_person🧔
man_beard🧔‍♂️
woman_beard🧔‍♀️
red_haired_man👨‍🦰
curly_haired_man👨‍🦱
white_haired_man👨‍🦳
bald_man👨‍🦲
woman👩
red_haired_woman👩‍🦰
person_red_hair🧑‍🦰
curly_haired_woman👩‍🦱
person_curly_hair🧑‍🦱
white_haired_woman👩‍🦳
person_white_hair🧑‍🦳
bald_woman👩‍🦲
person_bald🧑‍🦲
blond_haired_woman👱‍♀️
blond_haired_man👱‍♂️
older_adult🧓
older_man👴
older_woman👵
frowning_person🙍
frowning_man🙍‍♂️
frowning_woman🙍‍♀️
pouting_face🙎
pouting_man🙎‍♂️
pouting_woman🙎‍♀️
no_good🙅
no_good_man🙅‍♂️
no_good_woman🙅‍♀️
ok_person🙆
ok_man🙆‍♂️
ok_woman🙆‍♀️
tipping_hand_person💁
tipping_hand_man💁‍♂️
tipping_hand_woman💁‍♀️
raising_hand🙋
raising_hand_man🙋‍♂️
raising_hand_woman🙋‍♀️
deaf_person🧏
deaf_man🧏‍♂️
deaf_woman🧏‍♀️
bow🙇
bowing_man🙇‍♂️
bowing_woman🙇‍♀️
facepalm🤦
man_facepalming🤦‍♂️
woman_facepalming🤦‍♀️
shrug🤷
man_shrugging🤷‍♂️
woman_shrugging🤷‍♀️
health_worker🧑‍⚕️
man_health_worker👨‍⚕️
woman_health_worker👩‍⚕️
student🧑‍🎓
man_student👨‍🎓
woman_student👩‍🎓
teacher🧑‍🏫
man_teacher👨‍🏫
woman_teacher👩‍🏫
judge🧑‍⚖️
man_judge👨‍⚖️
woman_judge👩‍⚖️
farmer🧑‍🌾
man_farmer👨‍🌾
woman_farmer👩‍🌾
cook🧑‍🍳
man_cook👨‍🍳
woman_cook👩‍🍳
mechanic🧑‍🔧
man_mechanic👨‍🔧
woman_mechanic👩‍🔧
factory_worker🧑‍🏭
man_factory_worker👨‍🏭
woman_factory_worker👩‍🏭
office_worker🧑‍💼
man_office_worker👨‍💼
woman_office_worker👩‍💼
scientist🧑‍🔬
man_scientist👨‍🔬
woman_scientist👩‍🔬
technologist🧑‍💻
man_technologist👨‍💻
woman_technologist👩‍💻
singer🧑‍🎤
man_singer👨‍🎤
woman_singer👩‍🎤
artist🧑‍🎨
man_artist👨‍🎨
woman_artist👩‍🎨
pilot🧑‍✈️
man_pilot👨‍✈️
woman_pilot👩‍✈️
astronaut🧑‍🚀
man_astronaut👨‍🚀
woman_astronaut👩‍🚀
firefighter🧑‍🚒
man_firefighter👨‍🚒
woman_firefighter👩‍🚒
police_officer👮
policeman👮‍♂️
policewoman👮‍♀️
detective🕵️
male_detective🕵️‍♂️
female_detective🕵️‍♀️
guard💂
guardsman💂‍♂️
guardswoman💂‍♀️
ninja🥷
construction_worker👷
construction_worker_man👷‍♂️
construction_worker_woman👷‍♀️
prince🤴
princess👸
person_with_turban👳
man_with_turban👳‍♂️
woman_with_turban👳‍♀️
man_with_gua_pi_mao👲
woman_with_headscarf🧕
person_in_tuxedo🤵
man_in_tuxedo🤵‍♂️
woman_in_tuxedo🤵‍♀️
person_with_veil👰
man_with_veil👰‍♂️
woman_with_veil👰‍♀️
pregnant_woman🤰
breast_feeding🤱
woman_feeding_baby👩‍🍼
man_feeding_baby👨‍🍼
person_feeding_baby🧑‍🍼
angel👼
santa🎅
mrs_claus🤶
mx_claus🧑‍🎄
superhero🦸
superhero_man🦸‍♂️
superhero_woman🦸‍♀️
supervillain🦹
supervillain_man🦹‍♂️
supervillain_woman🦹‍♀️
mage🧙
mage_man🧙‍♂️
mage_woman🧙‍♀️
fairy🧚
fairy_man🧚‍♂️
fairy_woman🧚‍♀️
vampire🧛
vampire_man🧛‍♂️
vampire_woman🧛‍♀️
merperson🧜
merman🧜‍♂️
mermaid🧜‍♀️
elf🧝
elf_man🧝‍♂️
elf_woman🧝‍♀️
genie🧞
genie_man🧞‍♂️
genie_woman🧞‍♀️
zombie🧟
zombie_man🧟‍♂️
zombie_woman🧟‍♀️
massage💆
massage_man💆‍♂️
massage_woman💆‍♀️
haircut💇
haircut_man💇‍♂️
haircut_woman💇‍♀️
walking🚶
walking_man🚶‍♂️
walking_woman🚶‍♀️
standing_person🧍
standing_man🧍‍♂️
standing_woman🧍‍♀️
kneeling_person🧎
kneeling_man🧎‍♂️
kneeling_woman🧎‍♀️
person_with_probing_cane🧑‍🦯
man_with_probing_cane👨‍🦯
woman_with_probing_cane👩‍🦯
person_in_motorized_wheelchair🧑‍🦼
man_in_motorized_wheelchair👨‍🦼
woman_in_motorized_wheelchair👩‍🦼
person_in_manual_wheelchair🧑‍🦽
man_in_manual_wheelchair👨‍🦽
woman_in_manual_wheelchair👩‍🦽
runner🏃
running_man🏃‍♂️
running_woman🏃‍♀️
woman_dancing💃
man_dancing🕺
business_suit_levitating🕴️
dancers👯
dancing_men👯‍♂️
dancing_women👯‍♀️
sauna_person🧖
sauna_man🧖‍♂️
sauna_woman🧖‍♀️
climbing🧗
climbing_man🧗‍♂️
climbing_woman🧗‍♀️
person_fencing🤺
horse_racing🏇
skier⛷️
snowboarder🏂
golfing🏌️
golfing_man🏌️‍♂️
golfing_woman🏌️‍♀️
surfer🏄
surfing_man🏄‍♂️
surfing_woman🏄‍♀️
rowboat🚣
rowing_man🚣‍♂️
rowing_woman🚣‍♀️
swimmer🏊
swimming_man🏊‍♂️
swimming_woman🏊‍♀️
bouncing_ball_person⛹️
bouncing_ball_man⛹️‍♂️
bouncing_ball_woman⛹️‍♀️
weight_lifting🏋️
weight_lifting_man🏋️‍♂️
weight_lifting_woman🏋️‍♀️
bicyclist🚴
biking_man🚴‍♂️
biking_woman🚴‍♀️
mountain_bicyclist🚵
mountain_biking_man🚵‍♂️
mountain_biking_woman🚵‍♀️
cartwheeling🤸
man_cartwheeling🤸‍♂️
woman_cartwheeling🤸‍♀️
wrestling🤼
men_wrestling🤼‍♂️
women_wrestling🤼‍♀️
water_polo🤽
man_playing_water_polo🤽‍♂️
woman_playing_water_polo🤽‍♀️
handball_person🤾
man_playing_handball🤾‍♂️
woman_playing_handball🤾‍♀️
juggling_person🤹
man_juggling🤹‍♂️
woman_juggling🤹‍♀️
lotus_position🧘
lotus_position_man🧘‍♂️
lotus_position_woman🧘‍♀️
bath🛀
sleeping_bed🛌
people_holding_hands🧑‍🤝‍🧑
two_women_holding_hands👭
couple👫
two_men_holding_hands👬
couplekiss💏
couplekiss_man_woman👩‍❤️‍💋‍👨
couplekiss_man_man👨‍❤️‍💋‍👨
couplekiss_woman_woman👩‍❤️‍💋‍👩
couple_with_heart💑
couple_with_heart_woman_man👩‍❤️‍👨
couple_with_heart_man_man👨‍❤️‍👨
couple_with_heart_woman_woman👩‍❤️‍👩
family👪
family_man_woman_boy👨‍👩‍👦
family_man_woman_girl👨‍👩‍👧
family_man_woman_girl_boy👨‍👩‍👧‍👦
family_man_woman_boy_boy👨‍👩‍👦‍👦
family_man_woman_girl_girl👨‍👩‍👧‍👧
family_man_man_boy👨‍👨‍👦
family_man_man_girl👨‍👨‍👧
family_man_man_girl_boy👨‍👨‍👧‍👦
family_man_man_boy_boy👨‍👨‍👦‍👦
family_man_man_girl_girl👨‍👨‍👧‍👧
family_woman_woman_boy👩‍👩‍👦
family_woman_woman_girl👩‍👩‍👧
family_woman_woman_girl_boy👩‍👩‍👧‍👦
family_woman_woman_boy_boy👩‍👩‍👦‍👦
family_woman_woman_girl_girl👩‍👩‍👧‍👧
family_man_boy👨‍👦
family_man_boy_boy👨‍👦‍👦
family_man_girl👨‍👧
family_man_girl_boy👨‍👧‍👦
family_man_girl_girl👨‍👧‍👧
family_woman_boy👩‍👦
family_woman_boy_boy👩‍👦‍👦
family_woman_girl👩‍👧
family_woman_girl_boy👩‍👧‍👦
family_woman_girl_girl👩‍👧‍👧
speaking_head🗣️
bust_in_silhouette👤
busts_in_silhouette👥
people_hugging🫂
footprints👣
monkey_face🐵
monkey🐒
gorilla🦍
orangutan🦧
dog🐶
dog2🐕
guide_dog🦮
service_dog🐕‍🦺
poodle🐩
wolf🐺
fox_face🦊
raccoon🦝
cat🐱
cat2🐈
black_cat🐈‍⬛
lion🦁
tiger🐯
tiger2🐅
leopard🐆
horse🐴
racehorse🐎
unicorn🦄
zebra🦓
deer🦌
bison🦬
cow🐮
ox🐂
water_buffalo🐃
cow2🐄
pig🐷
pig2🐖
boar🐗
pig_nose🐽
ram🐏
sheep🐑
goat🐐
dromedary_camel🐪
camel🐫
llama🦙
giraffe🦒
elephant🐘
mammoth🦣
rhinoceros🦏
hippopotamus🦛
mouse🐭
mouse2🐁
rat🐀
hamster🐹
rabbit🐰
rabbit2🐇
chipmunk🐿️
beaver🦫
hedgehog🦔
bat🦇
bear🐻
polar_bear🐻‍❄️
koala🐨
panda_face🐼
sloth🦥
otter🦦
skunk🦨
kangaroo🦘
badger🦡
feet🐾
turkey🦃
chicken🐔
rooster🐓
hatching_chick🐣
baby_chick🐤
hatched_chick🐥
bird🐦
penguin🐧
dove🕊️
eagle🦅
duck🦆
swan🦢
owl🦉
dodo🦤
feather🪶
flamingo🦩
peacock🦚
parrot🦜
frog🐸
crocodile🐊
turtle🐢
lizard🦎
snake🐍
dragon_face🐲
dragon🐉
sauropod🦕
t-rex🦖
whale🐳
whale2🐋
dolphin🐬
seal🦭
fish🐟
tropical_fish🐠
blowfish🐡
shark🦈
octopus🐙
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + -
TagEmoji
octopus🐙
shell🐚
snail🐌
butterfly🦋
bug🐛
ant🐜
bee🐝
beetle🪲
lady_beetle🐞
cricket🦗
cockroach🪳
spider🕷️
spider_web🕸️
scorpion🦂
mosquito🦟
fly🪰
worm🪱
microbe🦠
bouquet💐
cherry_blossom🌸
white_flower💮
rosette🏵️
rose🌹
wilted_flower🥀
hibiscus🌺
sunflower🌻
blossom🌼
tulip🌷
seedling🌱
potted_plant🪴
evergreen_tree🌲
deciduous_tree🌳
palm_tree🌴
cactus🌵
ear_of_rice🌾
herb🌿
shamrock☘️
four_leaf_clover🍀
maple_leaf🍁
fallen_leaf🍂
leaves🍃
grapes🍇
melon🍈
watermelon🍉
tangerine🍊
lemon🍋
banana🍌
pineapple🍍
mango🥭
apple🍎
green_apple🍏
pear🍐
peach🍑
cherries🍒
strawberry🍓
blueberries🫐
kiwi_fruit🥝
tomato🍅
olive🫒
coconut🥥
avocado🥑
eggplant🍆
potato🥔
carrot🥕
corn🌽
hot_pepper🌶️
bell_pepper🫑
cucumber🥒
leafy_green🥬
broccoli🥦
garlic🧄
onion🧅
mushroom🍄
peanuts🥜
chestnut🌰
bread🍞
croissant🥐
baguette_bread🥖
flatbread🫓
pretzel🥨
bagel🥯
pancakes🥞
waffle🧇
cheese🧀
meat_on_bone🍖
poultry_leg🍗
cut_of_meat🥩
bacon🥓
hamburger🍔
fries🍟
pizza🍕
hotdog🌭
sandwich🥪
taco🌮
burrito🌯
tamale🫔
stuffed_flatbread🥙
falafel🧆
egg🥚
fried_egg🍳
shallow_pan_of_food🥘
stew🍲
fondue🫕
bowl_with_spoon🥣
green_salad🥗
popcorn🍿
butter🧈
salt🧂
canned_food🥫
bento🍱
rice_cracker🍘
rice_ball🍙
rice🍚
curry🍛
ramen🍜
spaghetti🍝
sweet_potato🍠
oden🍢
sushi🍣
fried_shrimp🍤
fish_cake🍥
moon_cake🥮
dango🍡
dumpling🥟
fortune_cookie🥠
takeout_box🥡
crab🦀
lobster🦞
shrimp🦐
squid🦑
oyster🦪
icecream🍦
shaved_ice🍧
ice_cream🍨
doughnut🍩
cookie🍪
birthday🎂
cake🍰
cupcake🧁
pie🥧
chocolate_bar🍫
candy🍬
lollipop🍭
custard🍮
honey_pot🍯
baby_bottle🍼
milk_glass🥛
coffee
teapot🫖
tea🍵
sake🍶
champagne🍾
wine_glass🍷
cocktail🍸
tropical_drink🍹
beer🍺
beers🍻
clinking_glasses🥂
tumbler_glass🥃
cup_with_straw🥤
bubble_tea🧋
beverage_box🧃
mate🧉
ice_cube🧊
chopsticks🥢
plate_with_cutlery🍽️
fork_and_knife🍴
spoon🥄
hocho🔪
amphora🏺
earth_africa🌍
earth_americas🌎
earth_asia🌏
globe_with_meridians🌐
world_map🗺️
japan🗾
compass🧭
mountain_snow🏔️
mountain⛰️
volcano🌋
mount_fuji🗻
camping🏕️
beach_umbrella🏖️
desert🏜️
desert_island🏝️
national_park🏞️
stadium🏟️
classical_building🏛️
building_construction🏗️
bricks🧱
rock🪨
wood🪵
hut🛖
houses🏘️
derelict_house🏚️
house🏠
house_with_garden🏡
office🏢
post_office🏣
european_post_office🏤
hospital🏥
bank🏦
hotel🏨
love_hotel🏩
convenience_store🏪
school🏫
department_store🏬
factory🏭
japanese_castle🏯
european_castle🏰
wedding💒
tokyo_tower🗼
statue_of_liberty🗽
church
mosque🕌
hindu_temple🛕
synagogue🕍
shinto_shrine⛩️
kaaba🕋
fountain
tent
foggy🌁
night_with_stars🌃
cityscape🏙️
sunrise_over_mountains🌄
sunrise🌅
city_sunset🌆
city_sunrise🌇
bridge_at_night🌉
hotsprings♨️
carousel_horse🎠
ferris_wheel🎡
roller_coaster🎢
barber💈
circus_tent🎪
steam_locomotive🚂
railway_car🚃
bullettrain_side🚄
bullettrain_front🚅
train2🚆
metro🚇
light_rail🚈
station🚉
tram🚊
monorail🚝
mountain_railway🚞
train🚋
bus🚌
oncoming_bus🚍
trolleybus🚎
minibus🚐
ambulance🚑
fire_engine🚒
police_car🚓
oncoming_police_car🚔
taxi🚕
oncoming_taxi🚖
car🚗
oncoming_automobile🚘
blue_car🚙
pickup_truck🛻
truck🚚
articulated_lorry🚛
tractor🚜
racing_car🏎️
motorcycle🏍️
motor_scooter🛵
manual_wheelchair🦽
motorized_wheelchair🦼
auto_rickshaw🛺
bike🚲
kick_scooter🛴
skateboard🛹
roller_skate🛼
busstop🚏
motorway🛣️
railway_track🛤️
oil_drum🛢️
fuelpump
rotating_light🚨
traffic_light🚥
vertical_traffic_light🚦
stop_sign🛑
construction🚧
anchor
boat
canoe🛶
speedboat🚤
passenger_ship🛳️
ferry⛴️
motor_boat🛥️
ship🚢
airplane✈️
small_airplane🛩️
flight_departure🛫
flight_arrival🛬
parachute🪂
seat💺
helicopter🚁
suspension_railway🚟
mountain_cableway🚠
aerial_tramway🚡
artificial_satellite🛰️
rocket🚀
flying_saucer🛸
bellhop_bell🛎️
luggage🧳
hourglass
hourglass_flowing_sand
watch
alarm_clock
stopwatch⏱️
timer_clock⏲️
mantelpiece_clock🕰️
clock12🕛
clock1230🕧
clock1🕐
clock130🕜
clock2🕑
clock230🕝
clock3🕒
clock330🕞
clock4🕓
clock430🕟
clock5🕔
clock530🕠
clock6🕕
clock630🕡
clock7🕖
clock730🕢
clock8🕗
clock830🕣
clock9🕘
clock930🕤
clock10🕙
clock1030🕥
clock11🕚
clock1130🕦
new_moon🌑
waxing_crescent_moon🌒
first_quarter_moon🌓
moon🌔
full_moon🌕
waning_gibbous_moon🌖
last_quarter_moon🌗
waning_crescent_moon🌘
crescent_moon🌙
new_moon_with_face🌚
first_quarter_moon_with_face🌛
last_quarter_moon_with_face🌜
thermometer🌡️
sunny☀️
full_moon_with_face🌝
sun_with_face🌞
ringed_planet🪐
star
star2🌟
stars🌠
milky_way🌌
cloud☁️
partly_sunny
cloud_with_lightning_and_rain⛈️
sun_behind_small_cloud🌤️
sun_behind_large_cloud🌥️
sun_behind_rain_cloud🌦️
cloud_with_rain🌧️
cloud_with_snow🌨️
cloud_with_lightning🌩️
tornado🌪️
fog🌫️
wind_face🌬️
cyclone🌀
rainbow🌈
closed_umbrella🌂
open_umbrella☂️
umbrella
parasol_on_ground⛱️
zap
snowflake❄️
snowman_with_snow☃️
snowman
comet☄️
fire🔥
droplet💧
ocean🌊
jack_o_lantern🎃
christmas_tree🎄
fireworks🎆
sparkler🎇
firecracker🧨
sparkles
balloon🎈
tada🎉
confetti_ball🎊
tanabata_tree🎋
bamboo🎍
dolls🎎
flags🎏
wind_chime🎐
rice_scene🎑
red_envelope🧧
ribbon🎀
gift🎁
reminder_ribbon🎗️
tickets🎟️
ticket🎫
medal_military🎖️
trophy🏆
medal_sports🏅
1st_place_medal🥇
2nd_place_medal🥈
3rd_place_medal🥉
soccer
baseball
softball🥎
basketball🏀
volleyball🏐
football🏈
rugby_football🏉
tennis🎾
flying_disc🥏
bowling🎳
cricket_game🏏
field_hockey🏑
ice_hockey🏒
lacrosse🥍
ping_pong🏓
badminton🏸
boxing_glove🥊
martial_arts_uniform🥋
goal_net🥅
golf
ice_skate⛸️
fishing_pole_and_fish🎣
diving_mask🤿
running_shirt_with_sash🎽
ski🎿
sled🛷
curling_stone🥌
dart🎯
yo_yo🪀
kite🪁
8ball🎱
crystal_ball🔮
magic_wand🪄
nazar_amulet🧿
video_game🎮
joystick🕹️
slot_machine🎰
game_die🎲
jigsaw🧩
teddy_bear🧸
pinata🪅
nesting_dolls🪆
spades♠️
hearts♥️
diamonds♦️
clubs♣️
chess_pawn♟️
black_joker🃏
mahjong🀄
flower_playing_cards🎴
performing_arts🎭
framed_picture🖼️
art🎨
thread🧵
sewing_needle🪡
yarn🧶
knot🪢
eyeglasses👓
dark_sunglasses🕶️
goggles🥽
lab_coat🥼
safety_vest🦺
necktie👔
shirt👕
jeans👖
scarf🧣
gloves🧤
coat🧥
socks🧦
dress👗
kimono👘
sari🥻
one_piece_swimsuit🩱
swim_brief🩲
shorts🩳
bikini👙
womans_clothes👚
purse👛
handbag👜
pouch👝
shopping🛍️
school_satchel🎒
thong_sandal🩴
mans_shoe👞
athletic_shoe👟
hiking_boot🥾
flat_shoe🥿
high_heel👠
sandal👡
ballet_shoes🩰
boot👢
crown👑
womans_hat👒
tophat🎩
mortar_board🎓
billed_cap🧢
military_helmet🪖
rescue_worker_helmet⛑️
prayer_beads📿
lipstick💄
ring💍
gem💎
mute🔇
speaker🔈
sound🔉
loud_sound🔊
loudspeaker📢
mega📣
postal_horn📯
bell🔔
no_bell🔕
musical_score🎼
musical_note🎵
notes🎶
studio_microphone🎙️
level_slider🎚️
control_knobs🎛️
microphone🎤
headphones🎧
radio📻
saxophone🎷
accordion🪗
guitar🎸
musical_keyboard🎹
trumpet🎺
violin🎻
banjo🪕
drum🥁
long_drum🪘
iphone📱
calling📲
phone☎️
telephone_receiver📞
pager📟
fax📠
battery🔋
electric_plug🔌
computer💻
desktop_computer🖥️
printer🖨️
keyboard⌨️
computer_mouse🖱️
trackball🖲️
minidisc💽
floppy_disk💾
cd💿
dvd📀
abacus🧮
movie_camera🎥
film_strip🎞️
film_projector📽️
clapper🎬
tv📺
camera📷
camera_flash📸
video_camera📹
vhs📼
mag🔍
mag_right🔎
candle🕯️
bulb💡
flashlight🔦
izakaya_lantern🏮
diya_lamp🪔
notebook_with_decorative_cover📔
closed_book📕
book📖
green_book📗
blue_book📘
orange_book📙
books📚
notebook📓
ledger📒
page_with_curl📃
scroll📜
page_facing_up📄
newspaper📰
newspaper_roll🗞️
bookmark_tabs📑
bookmark🔖
label🏷️
moneybag💰
coin🪙
yen💴
dollar💵
euro💶
pound💷
money_with_wings💸
credit_card💳
receipt🧾
chart💹
envelope✉️
email📧
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
octopus🐙
shell🐚
snail🐌
butterfly🦋
bug🐛
ant🐜
bee🐝
beetle🪲
lady_beetle🐞
cricket🦗
cockroach🪳
spider🕷️
spider_web🕸️
scorpion🦂
mosquito🦟
fly🪰
worm🪱
microbe🦠
bouquet💐
cherry_blossom🌸
white_flower💮
rosette🏵️
rose🌹
wilted_flower🥀
hibiscus🌺
sunflower🌻
blossom🌼
tulip🌷
seedling🌱
potted_plant🪴
evergreen_tree🌲
deciduous_tree🌳
palm_tree🌴
cactus🌵
ear_of_rice🌾
herb🌿
shamrock☘️
four_leaf_clover🍀
maple_leaf🍁
fallen_leaf🍂
leaves🍃
grapes🍇
melon🍈
watermelon🍉
tangerine🍊
lemon🍋
banana🍌
pineapple🍍
mango🥭
apple🍎
green_apple🍏
pear🍐
peach🍑
cherries🍒
strawberry🍓
blueberries🫐
kiwi_fruit🥝
tomato🍅
olive🫒
coconut🥥
avocado🥑
eggplant🍆
potato🥔
carrot🥕
corn🌽
hot_pepper🌶️
bell_pepper🫑
cucumber🥒
leafy_green🥬
broccoli🥦
garlic🧄
onion🧅
mushroom🍄
peanuts🥜
chestnut🌰
bread🍞
croissant🥐
baguette_bread🥖
flatbread🫓
pretzel🥨
bagel🥯
pancakes🥞
waffle🧇
cheese🧀
meat_on_bone🍖
poultry_leg🍗
cut_of_meat🥩
bacon🥓
hamburger🍔
fries🍟
pizza🍕
hotdog🌭
sandwich🥪
taco🌮
burrito🌯
tamale🫔
stuffed_flatbread🥙
falafel🧆
egg🥚
fried_egg🍳
shallow_pan_of_food🥘
stew🍲
fondue🫕
bowl_with_spoon🥣
green_salad🥗
popcorn🍿
butter🧈
salt🧂
canned_food🥫
bento🍱
rice_cracker🍘
rice_ball🍙
rice🍚
curry🍛
ramen🍜
spaghetti🍝
sweet_potato🍠
oden🍢
sushi🍣
fried_shrimp🍤
fish_cake🍥
moon_cake🥮
dango🍡
dumpling🥟
fortune_cookie🥠
takeout_box🥡
crab🦀
lobster🦞
shrimp🦐
squid🦑
oyster🦪
icecream🍦
shaved_ice🍧
ice_cream🍨
doughnut🍩
cookie🍪
birthday🎂
cake🍰
cupcake🧁
pie🥧
chocolate_bar🍫
candy🍬
lollipop🍭
custard🍮
honey_pot🍯
baby_bottle🍼
milk_glass🥛
coffee
teapot🫖
tea🍵
sake🍶
champagne🍾
wine_glass🍷
cocktail🍸
tropical_drink🍹
beer🍺
beers🍻
clinking_glasses🥂
tumbler_glass🥃
cup_with_straw🥤
bubble_tea🧋
beverage_box🧃
mate🧉
ice_cube🧊
chopsticks🥢
plate_with_cutlery🍽️
fork_and_knife🍴
spoon🥄
hocho🔪
amphora🏺
earth_africa🌍
earth_americas🌎
earth_asia🌏
globe_with_meridians🌐
world_map🗺️
japan🗾
compass🧭
mountain_snow🏔️
mountain⛰️
volcano🌋
mount_fuji🗻
camping🏕️
beach_umbrella🏖️
desert🏜️
desert_island🏝️
national_park🏞️
stadium🏟️
classical_building🏛️
building_construction🏗️
bricks🧱
rock🪨
wood🪵
hut🛖
houses🏘️
derelict_house🏚️
house🏠
house_with_garden🏡
office🏢
post_office🏣
european_post_office🏤
hospital🏥
bank🏦
hotel🏨
love_hotel🏩
convenience_store🏪
school🏫
department_store🏬
factory🏭
japanese_castle🏯
european_castle🏰
wedding💒
tokyo_tower🗼
statue_of_liberty🗽
church
mosque🕌
hindu_temple🛕
synagogue🕍
shinto_shrine⛩️
kaaba🕋
fountain
tent
foggy🌁
night_with_stars🌃
cityscape🏙️
sunrise_over_mountains🌄
sunrise🌅
city_sunset🌆
city_sunrise🌇
bridge_at_night🌉
hotsprings♨️
carousel_horse🎠
ferris_wheel🎡
roller_coaster🎢
barber💈
circus_tent🎪
steam_locomotive🚂
railway_car🚃
bullettrain_side🚄
bullettrain_front🚅
train2🚆
metro🚇
light_rail🚈
station🚉
tram🚊
monorail🚝
mountain_railway🚞
train🚋
bus🚌
oncoming_bus🚍
trolleybus🚎
minibus🚐
ambulance🚑
fire_engine🚒
police_car🚓
oncoming_police_car🚔
taxi🚕
oncoming_taxi🚖
car🚗
oncoming_automobile🚘
blue_car🚙
pickup_truck🛻
truck🚚
articulated_lorry🚛
tractor🚜
racing_car🏎️
motorcycle🏍️
motor_scooter🛵
manual_wheelchair🦽
motorized_wheelchair🦼
auto_rickshaw🛺
bike🚲
kick_scooter🛴
skateboard🛹
roller_skate🛼
busstop🚏
motorway🛣️
railway_track🛤️
oil_drum🛢️
fuelpump
rotating_light🚨
traffic_light🚥
vertical_traffic_light🚦
stop_sign🛑
construction🚧
anchor
boat
canoe🛶
speedboat🚤
passenger_ship🛳️
ferry⛴️
motor_boat🛥️
ship🚢
airplane✈️
small_airplane🛩️
flight_departure🛫
flight_arrival🛬
parachute🪂
seat💺
helicopter🚁
suspension_railway🚟
mountain_cableway🚠
aerial_tramway🚡
artificial_satellite🛰️
rocket🚀
flying_saucer🛸
bellhop_bell🛎️
luggage🧳
hourglass
hourglass_flowing_sand
watch
alarm_clock
stopwatch⏱️
timer_clock⏲️
mantelpiece_clock🕰️
clock12🕛
clock1230🕧
clock1🕐
clock130🕜
clock2🕑
clock230🕝
clock3🕒
clock330🕞
clock4🕓
clock430🕟
clock5🕔
clock530🕠
clock6🕕
clock630🕡
clock7🕖
clock730🕢
clock8🕗
clock830🕣
clock9🕘
clock930🕤
clock10🕙
clock1030🕥
clock11🕚
clock1130🕦
new_moon🌑
waxing_crescent_moon🌒
first_quarter_moon🌓
moon🌔
full_moon🌕
waning_gibbous_moon🌖
last_quarter_moon🌗
waning_crescent_moon🌘
crescent_moon🌙
new_moon_with_face🌚
first_quarter_moon_with_face🌛
last_quarter_moon_with_face🌜
thermometer🌡️
sunny☀️
full_moon_with_face🌝
sun_with_face🌞
ringed_planet🪐
star
star2🌟
stars🌠
milky_way🌌
cloud☁️
partly_sunny
cloud_with_lightning_and_rain⛈️
sun_behind_small_cloud🌤️
sun_behind_large_cloud🌥️
sun_behind_rain_cloud🌦️
cloud_with_rain🌧️
cloud_with_snow🌨️
cloud_with_lightning🌩️
tornado🌪️
fog🌫️
wind_face🌬️
cyclone🌀
rainbow🌈
closed_umbrella🌂
open_umbrella☂️
umbrella
parasol_on_ground⛱️
zap
snowflake❄️
snowman_with_snow☃️
snowman
comet☄️
fire🔥
droplet💧
ocean🌊
jack_o_lantern🎃
christmas_tree🎄
fireworks🎆
sparkler🎇
firecracker🧨
sparkles
balloon🎈
tada🎉
confetti_ball🎊
tanabata_tree🎋
bamboo🎍
dolls🎎
flags🎏
wind_chime🎐
rice_scene🎑
red_envelope🧧
ribbon🎀
gift🎁
reminder_ribbon🎗️
tickets🎟️
ticket🎫
medal_military🎖️
trophy🏆
medal_sports🏅
1st_place_medal🥇
2nd_place_medal🥈
3rd_place_medal🥉
soccer
baseball
softball🥎
basketball🏀
volleyball🏐
football🏈
rugby_football🏉
tennis🎾
flying_disc🥏
bowling🎳
cricket_game🏏
field_hockey🏑
ice_hockey🏒
lacrosse🥍
ping_pong🏓
badminton🏸
boxing_glove🥊
martial_arts_uniform🥋
goal_net🥅
golf
ice_skate⛸️
fishing_pole_and_fish🎣
diving_mask🤿
running_shirt_with_sash🎽
ski🎿
sled🛷
curling_stone🥌
dart🎯
yo_yo🪀
kite🪁
8ball🎱
crystal_ball🔮
magic_wand🪄
nazar_amulet🧿
video_game🎮
joystick🕹️
slot_machine🎰
game_die🎲
jigsaw🧩
teddy_bear🧸
pinata🪅
nesting_dolls🪆
spades♠️
hearts♥️
diamonds♦️
clubs♣️
chess_pawn♟️
black_joker🃏
mahjong🀄
flower_playing_cards🎴
performing_arts🎭
framed_picture🖼️
art🎨
thread🧵
sewing_needle🪡
yarn🧶
knot🪢
eyeglasses👓
dark_sunglasses🕶️
goggles🥽
lab_coat🥼
safety_vest🦺
necktie👔
shirt👕
jeans👖
scarf🧣
gloves🧤
coat🧥
socks🧦
dress👗
kimono👘
sari🥻
one_piece_swimsuit🩱
swim_brief🩲
shorts🩳
bikini👙
womans_clothes👚
purse👛
handbag👜
pouch👝
shopping🛍️
school_satchel🎒
thong_sandal🩴
mans_shoe👞
athletic_shoe👟
hiking_boot🥾
flat_shoe🥿
high_heel👠
sandal👡
ballet_shoes🩰
boot👢
crown👑
womans_hat👒
tophat🎩
mortar_board🎓
billed_cap🧢
military_helmet🪖
rescue_worker_helmet⛑️
prayer_beads📿
lipstick💄
ring💍
gem💎
mute🔇
speaker🔈
sound🔉
loud_sound🔊
loudspeaker📢
mega📣
postal_horn📯
bell🔔
no_bell🔕
musical_score🎼
musical_note🎵
notes🎶
studio_microphone🎙️
level_slider🎚️
control_knobs🎛️
microphone🎤
headphones🎧
radio📻
saxophone🎷
accordion🪗
guitar🎸
musical_keyboard🎹
trumpet🎺
violin🎻
banjo🪕
drum🥁
long_drum🪘
iphone📱
calling📲
phone☎️
telephone_receiver📞
pager📟
fax📠
battery🔋
electric_plug🔌
computer💻
desktop_computer🖥️
printer🖨️
keyboard⌨️
computer_mouse🖱️
trackball🖲️
minidisc💽
floppy_disk💾
cd💿
dvd📀
abacus🧮
movie_camera🎥
film_strip🎞️
film_projector📽️
clapper🎬
tv📺
camera📷
camera_flash📸
video_camera📹
vhs📼
mag🔍
mag_right🔎
candle🕯️
bulb💡
flashlight🔦
izakaya_lantern🏮
diya_lamp🪔
notebook_with_decorative_cover📔
closed_book📕
book📖
green_book📗
blue_book📘
orange_book📙
books📚
notebook📓
ledger📒
page_with_curl📃
scroll📜
page_facing_up📄
newspaper📰
newspaper_roll🗞️
bookmark_tabs📑
bookmark🔖
label🏷️
moneybag💰
coin🪙
yen💴
dollar💵
euro💶
pound💷
money_with_wings💸
credit_card💳
receipt🧾
chart💹
envelope✉️
email📧
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
TagEmoji
email📧
incoming_envelope📨
envelope_with_arrow📩
outbox_tray📤
inbox_tray📥
package📦
mailbox📫
mailbox_closed📪
mailbox_with_mail📬
mailbox_with_no_mail📭
postbox📮
ballot_box🗳️
pencil2✏️
black_nib✒️
fountain_pen🖋️
pen🖊️
paintbrush🖌️
crayon🖍️
memo📝
briefcase💼
file_folder📁
open_file_folder📂
card_index_dividers🗂️
date📅
calendar📆
spiral_notepad🗒️
spiral_calendar🗓️
card_index📇
chart_with_upwards_trend📈
chart_with_downwards_trend📉
bar_chart📊
clipboard📋
pushpin📌
round_pushpin📍
paperclip📎
paperclips🖇️
straight_ruler📏
triangular_ruler📐
scissors✂️
card_file_box🗃️
file_cabinet🗄️
wastebasket🗑️
lock🔒
unlock🔓
lock_with_ink_pen🔏
closed_lock_with_key🔐
key🔑
old_key🗝️
hammer🔨
axe🪓
pick⛏️
hammer_and_pick⚒️
hammer_and_wrench🛠️
dagger🗡️
crossed_swords⚔️
gun🔫
boomerang🪃
bow_and_arrow🏹
shield🛡️
carpentry_saw🪚
wrench🔧
screwdriver🪛
nut_and_bolt🔩
gear⚙️
clamp🗜️
balance_scale⚖️
probing_cane🦯
link🔗
chains⛓️
hook🪝
toolbox🧰
magnet🧲
ladder🪜
alembic⚗️
test_tube🧪
petri_dish🧫
dna🧬
microscope🔬
telescope🔭
satellite📡
syringe💉
drop_of_blood🩸
pill💊
adhesive_bandage🩹
stethoscope🩺
door🚪
elevator🛗
mirror🪞
window🪟
bed🛏️
couch_and_lamp🛋️
chair🪑
toilet🚽
plunger🪠
shower🚿
bathtub🛁
mouse_trap🪤
razor🪒
lotion_bottle🧴
safety_pin🧷
broom🧹
basket🧺
roll_of_paper🧻
bucket🪣
soap🧼
toothbrush🪥
sponge🧽
fire_extinguisher🧯
shopping_cart🛒
smoking🚬
coffin⚰️
headstone🪦
funeral_urn⚱️
moyai🗿
placard🪧
atm🏧
put_litter_in_its_place🚮
potable_water🚰
wheelchair
mens🚹
womens🚺
restroom🚻
baby_symbol🚼
wc🚾
passport_control🛂
customs🛃
baggage_claim🛄
left_luggage🛅
warning⚠️
children_crossing🚸
no_entry
no_entry_sign🚫
no_bicycles🚳
no_smoking🚭
do_not_litter🚯
non-potable_water🚱
no_pedestrians🚷
no_mobile_phones📵
underage🔞
radioactive☢️
biohazard☣️
arrow_up⬆️
arrow_upper_right↗️
arrow_right➡️
arrow_lower_right↘️
arrow_down⬇️
arrow_lower_left↙️
arrow_left⬅️
arrow_upper_left↖️
arrow_up_down↕️
left_right_arrow↔️
leftwards_arrow_with_hook↩️
arrow_right_hook↪️
arrow_heading_up⤴️
arrow_heading_down⤵️
arrows_clockwise🔃
arrows_counterclockwise🔄
back🔙
end🔚
on🔛
soon🔜
top🔝
place_of_worship🛐
atom_symbol⚛️
om🕉️
star_of_david✡️
wheel_of_dharma☸️
yin_yang☯️
latin_cross✝️
orthodox_cross☦️
star_and_crescent☪️
peace_symbol☮️
menorah🕎
six_pointed_star🔯
aries
taurus
gemini
cancer
leo
virgo
libra
scorpius
sagittarius
capricorn
aquarius
pisces
ophiuchus
twisted_rightwards_arrows🔀
repeat🔁
repeat_one🔂
arrow_forward▶️
fast_forward
next_track_button⏭️
play_or_pause_button⏯️
arrow_backward◀️
rewind
previous_track_button⏮️
arrow_up_small🔼
arrow_double_up
arrow_down_small🔽
arrow_double_down
pause_button⏸️
stop_button⏹️
record_button⏺️
eject_button⏏️
cinema🎦
low_brightness🔅
high_brightness🔆
signal_strength📶
vibration_mode📳
mobile_phone_off📴
female_sign♀️
male_sign♂️
transgender_symbol⚧️
heavy_multiplication_x✖️
heavy_plus_sign
heavy_minus_sign
heavy_division_sign
infinity♾️
bangbang‼️
interrobang⁉️
question
grey_question
grey_exclamation
exclamation
wavy_dash〰️
currency_exchange💱
heavy_dollar_sign💲
medical_symbol⚕️
recycle♻️
fleur_de_lis⚜️
trident🔱
name_badge📛
beginner🔰
o
white_check_mark
ballot_box_with_check☑️
heavy_check_mark✔️
x
negative_squared_cross_mark
curly_loop
loop
part_alternation_mark〽️
eight_spoked_asterisk✳️
eight_pointed_black_star✴️
sparkle❇️
copyright©️
registered®️
tm™️
hash#️⃣
asterisk*️⃣
zero0️⃣
one1️⃣
two2️⃣
three3️⃣
four4️⃣
five5️⃣
six6️⃣
seven7️⃣
eight8️⃣
nine9️⃣
keycap_ten🔟
capital_abcd🔠
abcd🔡
1234🔢
symbols🔣
abc🔤
a🅰️
ab🆎
b🅱️
cl🆑
cool🆒
free🆓
information_sourceℹ️
id🆔
mⓂ️
new🆕
ng🆖
o2🅾️
ok🆗
parking🅿️
sos🆘
up🆙
vs🆚
koko🈁
sa🈂️
u6708🈷️
u6709🈶
u6307🈯
ideograph_advantage🉐
u5272🈹
u7121🈚
u7981🈲
accept🉑
u7533🈸
u5408🈴
u7a7a🈳
congratulations㊗️
secret㊙️
u55b6🈺
u6e80🈵
red_circle🔴
orange_circle🟠
yellow_circle🟡
green_circle🟢
large_blue_circle🔵
purple_circle🟣
brown_circle🟤
black_circle
white_circle
red_square🟥
orange_square🟧
yellow_square🟨
green_square🟩
blue_square🟦
purple_square🟪
brown_square🟫
black_large_square
white_large_square
black_medium_square◼️
white_medium_square◻️
black_medium_small_square
white_medium_small_square
black_small_square▪️
white_small_square▫️
large_orange_diamond🔶
large_blue_diamond🔷
small_orange_diamond🔸
small_blue_diamond🔹
small_red_triangle🔺
small_red_triangle_down🔻
diamond_shape_with_a_dot_inside💠
radio_button🔘
white_square_button🔳
black_square_button🔲
checkered_flag🏁
triangular_flag_on_post🚩
crossed_flags🎌
black_flag🏴
white_flag🏳️
rainbow_flag🏳️‍🌈
transgender_flag🏳️‍⚧️
pirate_flag🏴‍☠️
ascension_island🇦🇨
andorra🇦🇩
united_arab_emirates🇦🇪
afghanistan🇦🇫
antigua_barbuda🇦🇬
anguilla🇦🇮
albania🇦🇱
armenia🇦🇲
angola🇦🇴
antarctica🇦🇶
argentina🇦🇷
american_samoa🇦🇸
austria🇦🇹
australia🇦🇺
aruba🇦🇼
aland_islands🇦🇽
azerbaijan🇦🇿
bosnia_herzegovina🇧🇦
barbados🇧🇧
bangladesh🇧🇩
belgium🇧🇪
burkina_faso🇧🇫
bulgaria🇧🇬
bahrain🇧🇭
burundi🇧🇮
benin🇧🇯
st_barthelemy🇧🇱
bermuda🇧🇲
brunei🇧🇳
bolivia🇧🇴
caribbean_netherlands🇧🇶
brazil🇧🇷
bahamas🇧🇸
bhutan🇧🇹
bouvet_island🇧🇻
botswana🇧🇼
belarus🇧🇾
belize🇧🇿
canada🇨🇦
cocos_islands🇨🇨
congo_kinshasa🇨🇩
central_african_republic🇨🇫
congo_brazzaville🇨🇬
switzerland🇨🇭
cote_divoire🇨🇮
cook_islands🇨🇰
chile🇨🇱
cameroon🇨🇲
cn🇨🇳
colombia🇨🇴
clipperton_island🇨🇵
costa_rica🇨🇷
cuba🇨🇺
cape_verde🇨🇻
curacao🇨🇼
christmas_island🇨🇽
cyprus🇨🇾
czech_republic🇨🇿
de🇩🇪
diego_garcia🇩🇬
djibouti🇩🇯
denmark🇩🇰
dominica🇩🇲
dominican_republic🇩🇴
algeria🇩🇿
ceuta_melilla🇪🇦
ecuador🇪🇨
estonia🇪🇪
egypt🇪🇬
western_sahara🇪🇭
eritrea🇪🇷
es🇪🇸
ethiopia🇪🇹
eu🇪🇺
finland🇫🇮
fiji🇫🇯
falkland_islands🇫🇰
micronesia🇫🇲
faroe_islands🇫🇴
fr🇫🇷
gabon🇬🇦
gb🇬🇧
grenada🇬🇩
georgia🇬🇪
french_guiana🇬🇫
guernsey🇬🇬
ghana🇬🇭
gibraltar🇬🇮
greenland🇬🇱
gambia🇬🇲
guinea🇬🇳
guadeloupe🇬🇵
equatorial_guinea🇬🇶
greece🇬🇷
south_georgia_south_sandwich_islands🇬🇸
guatemala🇬🇹
guam🇬🇺
guinea_bissau🇬🇼
guyana🇬🇾
hong_kong🇭🇰
heard_mcdonald_islands🇭🇲
honduras🇭🇳
croatia🇭🇷
haiti🇭🇹
hungary🇭🇺
canary_islands🇮🇨
indonesia🇮🇩
ireland🇮🇪
israel🇮🇱
isle_of_man🇮🇲
india🇮🇳
british_indian_ocean_territory🇮🇴
iraq🇮🇶
iran🇮🇷
iceland🇮🇸
it🇮🇹
jersey🇯🇪
jamaica🇯🇲
jordan🇯🇴
jp🇯🇵
kenya🇰🇪
kyrgyzstan🇰🇬
cambodia🇰🇭
kiribati🇰🇮
comoros🇰🇲
st_kitts_nevis🇰🇳
north_korea🇰🇵
kr🇰🇷
kuwait🇰🇼
cayman_islands🇰🇾
kazakhstan🇰🇿
laos🇱🇦
lebanon🇱🇧
st_lucia🇱🇨
liechtenstein🇱🇮
sri_lanka🇱🇰
liberia🇱🇷
lesotho🇱🇸
lithuania🇱🇹
luxembourg🇱🇺
latvia🇱🇻
libya🇱🇾
morocco🇲🇦
monaco🇲🇨
moldova🇲🇩
montenegro🇲🇪
st_martin🇲🇫
madagascar🇲🇬
marshall_islands🇲🇭
macedonia🇲🇰
mali🇲🇱
myanmar🇲🇲
mongolia🇲🇳
macau🇲🇴
northern_mariana_islands🇲🇵
martinique🇲🇶
mauritania🇲🇷
montserrat🇲🇸
malta🇲🇹
mauritius🇲🇺
maldives🇲🇻
malawi🇲🇼
mexico🇲🇽
malaysia🇲🇾
mozambique🇲🇿
namibia🇳🇦
new_caledonia🇳🇨
niger🇳🇪
norfolk_island🇳🇫
nigeria🇳🇬
nicaragua🇳🇮
netherlands🇳🇱
norway🇳🇴
nepal🇳🇵
nauru🇳🇷
niue🇳🇺
new_zealand🇳🇿
oman🇴🇲
panama🇵🇦
peru🇵🇪
french_polynesia🇵🇫
papua_new_guinea🇵🇬
philippines🇵🇭
pakistan🇵🇰
poland🇵🇱
st_pierre_miquelon🇵🇲
pitcairn_islands🇵🇳
puerto_rico🇵🇷
palestinian_territories🇵🇸
portugal🇵🇹
palau🇵🇼
paraguay🇵🇾
qatar🇶🇦
reunion🇷🇪
romania🇷🇴
serbia🇷🇸
ru🇷🇺
rwanda🇷🇼
saudi_arabia🇸🇦
solomon_islands🇸🇧
seychelles🇸🇨
sudan🇸🇩
sweden🇸🇪
singapore🇸🇬
st_helena🇸🇭
slovenia🇸🇮
svalbard_jan_mayen🇸🇯
slovakia🇸🇰
sierra_leone🇸🇱
san_marino🇸🇲
senegal🇸🇳
somalia🇸🇴
suriname🇸🇷
south_sudan🇸🇸
sao_tome_principe🇸🇹
el_salvador🇸🇻
sint_maarten🇸🇽
syria🇸🇾
swaziland🇸🇿
tristan_da_cunha🇹🇦
turks_caicos_islands🇹🇨
chad🇹🇩
french_southern_territories🇹🇫
togo🇹🇬
thailand🇹🇭
tajikistan🇹🇯
tokelau🇹🇰
timor_leste🇹🇱
turkmenistan🇹🇲
tunisia🇹🇳
tonga🇹🇴
tr🇹🇷
trinidad_tobago🇹🇹
tuvalu🇹🇻
taiwan🇹🇼
tanzania🇹🇿
ukraine🇺🇦
uganda🇺🇬
us_outlying_islands🇺🇲
united_nations🇺🇳
us🇺🇸
uruguay🇺🇾
uzbekistan🇺🇿
vatican_city🇻🇦
st_vincent_grenadines🇻🇨
venezuela🇻🇪
british_virgin_islands🇻🇬
us_virgin_islands🇻🇮
vietnam🇻🇳
vanuatu🇻🇺
wallis_futuna🇼🇫
samoa🇼🇸
kosovo🇽🇰
yemen🇾🇪
mayotte🇾🇹
south_africa🇿🇦
zambia🇿🇲
zimbabwe🇿🇼
england🏴󠁧󠁢󠁥󠁮󠁧󠁿
scotland🏴󠁧󠁢󠁳󠁣󠁴󠁿
wales🏴󠁧󠁢󠁷󠁬󠁳󠁿
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
email📧
incoming_envelope📨
envelope_with_arrow📩
outbox_tray📤
inbox_tray📥
package📦
mailbox📫
mailbox_closed📪
mailbox_with_mail📬
mailbox_with_no_mail📭
postbox📮
ballot_box🗳️
pencil2✏️
black_nib✒️
fountain_pen🖋️
pen🖊️
paintbrush🖌️
crayon🖍️
memo📝
briefcase💼
file_folder📁
open_file_folder📂
card_index_dividers🗂️
date📅
calendar📆
spiral_notepad🗒️
spiral_calendar🗓️
card_index📇
chart_with_upwards_trend📈
chart_with_downwards_trend📉
bar_chart📊
clipboard📋
pushpin📌
round_pushpin📍
paperclip📎
paperclips🖇️
straight_ruler📏
triangular_ruler📐
scissors✂️
card_file_box🗃️
file_cabinet🗄️
wastebasket🗑️
lock🔒
unlock🔓
lock_with_ink_pen🔏
closed_lock_with_key🔐
key🔑
old_key🗝️
hammer🔨
axe🪓
pick⛏️
hammer_and_pick⚒️
hammer_and_wrench🛠️
dagger🗡️
crossed_swords⚔️
gun🔫
boomerang🪃
bow_and_arrow🏹
shield🛡️
carpentry_saw🪚
wrench🔧
screwdriver🪛
nut_and_bolt🔩
gear⚙️
clamp🗜️
balance_scale⚖️
probing_cane🦯
link🔗
chains⛓️
hook🪝
toolbox🧰
magnet🧲
ladder🪜
alembic⚗️
test_tube🧪
petri_dish🧫
dna🧬
microscope🔬
telescope🔭
satellite📡
syringe💉
drop_of_blood🩸
pill💊
adhesive_bandage🩹
stethoscope🩺
door🚪
elevator🛗
mirror🪞
window🪟
bed🛏️
couch_and_lamp🛋️
chair🪑
toilet🚽
plunger🪠
shower🚿
bathtub🛁
mouse_trap🪤
razor🪒
lotion_bottle🧴
safety_pin🧷
broom🧹
basket🧺
roll_of_paper🧻
bucket🪣
soap🧼
toothbrush🪥
sponge🧽
fire_extinguisher🧯
shopping_cart🛒
smoking🚬
coffin⚰️
headstone🪦
funeral_urn⚱️
moyai🗿
placard🪧
atm🏧
put_litter_in_its_place🚮
potable_water🚰
wheelchair
mens🚹
womens🚺
restroom🚻
baby_symbol🚼
wc🚾
passport_control🛂
customs🛃
baggage_claim🛄
left_luggage🛅
warning⚠️
children_crossing🚸
no_entry
no_entry_sign🚫
no_bicycles🚳
no_smoking🚭
do_not_litter🚯
non-potable_water🚱
no_pedestrians🚷
no_mobile_phones📵
underage🔞
radioactive☢️
biohazard☣️
arrow_up⬆️
arrow_upper_right↗️
arrow_right➡️
arrow_lower_right↘️
arrow_down⬇️
arrow_lower_left↙️
arrow_left⬅️
arrow_upper_left↖️
arrow_up_down↕️
left_right_arrow↔️
leftwards_arrow_with_hook↩️
arrow_right_hook↪️
arrow_heading_up⤴️
arrow_heading_down⤵️
arrows_clockwise🔃
arrows_counterclockwise🔄
back🔙
end🔚
on🔛
soon🔜
top🔝
place_of_worship🛐
atom_symbol⚛️
om🕉️
star_of_david✡️
wheel_of_dharma☸️
yin_yang☯️
latin_cross✝️
orthodox_cross☦️
star_and_crescent☪️
peace_symbol☮️
menorah🕎
six_pointed_star🔯
aries
taurus
gemini
cancer
leo
virgo
libra
scorpius
sagittarius
capricorn
aquarius
pisces
ophiuchus
twisted_rightwards_arrows🔀
repeat🔁
repeat_one🔂
arrow_forward▶️
fast_forward
next_track_button⏭️
play_or_pause_button⏯️
arrow_backward◀️
rewind
previous_track_button⏮️
arrow_up_small🔼
arrow_double_up
arrow_down_small🔽
arrow_double_down
pause_button⏸️
stop_button⏹️
record_button⏺️
eject_button⏏️
cinema🎦
low_brightness🔅
high_brightness🔆
signal_strength📶
vibration_mode📳
mobile_phone_off📴
female_sign♀️
male_sign♂️
transgender_symbol⚧️
heavy_multiplication_x✖️
heavy_plus_sign
heavy_minus_sign
heavy_division_sign
infinity♾️
bangbang‼️
interrobang⁉️
question
grey_question
grey_exclamation
exclamation
wavy_dash〰️
currency_exchange💱
heavy_dollar_sign💲
medical_symbol⚕️
recycle♻️
fleur_de_lis⚜️
trident🔱
name_badge📛
beginner🔰
o
white_check_mark
ballot_box_with_check☑️
heavy_check_mark✔️
x
negative_squared_cross_mark
curly_loop
loop
part_alternation_mark〽️
eight_spoked_asterisk✳️
eight_pointed_black_star✴️
sparkle❇️
copyright©️
registered®️
tm™️
hash#️⃣
asterisk*️⃣
zero0️⃣
one1️⃣
two2️⃣
three3️⃣
four4️⃣
five5️⃣
six6️⃣
seven7️⃣
eight8️⃣
nine9️⃣
keycap_ten🔟
capital_abcd🔠
abcd🔡
1234🔢
symbols🔣
abc🔤
a🅰️
ab🆎
b🅱️
cl🆑
cool🆒
free🆓
information_sourceℹ️
id🆔
mⓂ️
new🆕
ng🆖
o2🅾️
ok🆗
parking🅿️
sos🆘
up🆙
vs🆚
koko🈁
sa🈂️
u6708🈷️
u6709🈶
u6307🈯
ideograph_advantage🉐
u5272🈹
u7121🈚
u7981🈲
accept🉑
u7533🈸
u5408🈴
u7a7a🈳
congratulations㊗️
secret㊙️
u55b6🈺
u6e80🈵
red_circle🔴
orange_circle🟠
yellow_circle🟡
green_circle🟢
large_blue_circle🔵
purple_circle🟣
brown_circle🟤
black_circle
white_circle
red_square🟥
orange_square🟧
yellow_square🟨
green_square🟩
blue_square🟦
purple_square🟪
brown_square🟫
black_large_square
white_large_square
black_medium_square◼️
white_medium_square◻️
black_medium_small_square
white_medium_small_square
black_small_square▪️
white_small_square▫️
large_orange_diamond🔶
large_blue_diamond🔷
small_orange_diamond🔸
small_blue_diamond🔹
small_red_triangle🔺
small_red_triangle_down🔻
diamond_shape_with_a_dot_inside💠
radio_button🔘
white_square_button🔳
black_square_button🔲
checkered_flag🏁
triangular_flag_on_post🚩
crossed_flags🎌
black_flag🏴
white_flag🏳️
rainbow_flag🏳️‍🌈
transgender_flag🏳️‍⚧️
pirate_flag🏴‍☠️
ascension_island🇦🇨
andorra🇦🇩
united_arab_emirates🇦🇪
afghanistan🇦🇫
antigua_barbuda🇦🇬
anguilla🇦🇮
albania🇦🇱
armenia🇦🇲
angola🇦🇴
antarctica🇦🇶
argentina🇦🇷
american_samoa🇦🇸
austria🇦🇹
australia🇦🇺
aruba🇦🇼
aland_islands🇦🇽
azerbaijan🇦🇿
bosnia_herzegovina🇧🇦
barbados🇧🇧
bangladesh🇧🇩
belgium🇧🇪
burkina_faso🇧🇫
bulgaria🇧🇬
bahrain🇧🇭
burundi🇧🇮
benin🇧🇯
st_barthelemy🇧🇱
bermuda🇧🇲
brunei🇧🇳
bolivia🇧🇴
caribbean_netherlands🇧🇶
brazil🇧🇷
bahamas🇧🇸
bhutan🇧🇹
bouvet_island🇧🇻
botswana🇧🇼
belarus🇧🇾
belize🇧🇿
canada🇨🇦
cocos_islands🇨🇨
congo_kinshasa🇨🇩
central_african_republic🇨🇫
congo_brazzaville🇨🇬
switzerland🇨🇭
cote_divoire🇨🇮
cook_islands🇨🇰
chile🇨🇱
cameroon🇨🇲
cn🇨🇳
colombia🇨🇴
clipperton_island🇨🇵
costa_rica🇨🇷
cuba🇨🇺
cape_verde🇨🇻
curacao🇨🇼
christmas_island🇨🇽
cyprus🇨🇾
czech_republic🇨🇿
de🇩🇪
diego_garcia🇩🇬
djibouti🇩🇯
denmark🇩🇰
dominica🇩🇲
dominican_republic🇩🇴
algeria🇩🇿
ceuta_melilla🇪🇦
ecuador🇪🇨
estonia🇪🇪
egypt🇪🇬
western_sahara🇪🇭
eritrea🇪🇷
es🇪🇸
ethiopia🇪🇹
eu🇪🇺
finland🇫🇮
fiji🇫🇯
falkland_islands🇫🇰
micronesia🇫🇲
faroe_islands🇫🇴
fr🇫🇷
gabon🇬🇦
gb🇬🇧
grenada🇬🇩
georgia🇬🇪
french_guiana🇬🇫
guernsey🇬🇬
ghana🇬🇭
gibraltar🇬🇮
greenland🇬🇱
gambia🇬🇲
guinea🇬🇳
guadeloupe🇬🇵
equatorial_guinea🇬🇶
greece🇬🇷
south_georgia_south_sandwich_islands🇬🇸
guatemala🇬🇹
guam🇬🇺
guinea_bissau🇬🇼
guyana🇬🇾
hong_kong🇭🇰
heard_mcdonald_islands🇭🇲
honduras🇭🇳
croatia🇭🇷
haiti🇭🇹
hungary🇭🇺
canary_islands🇮🇨
indonesia🇮🇩
ireland🇮🇪
israel🇮🇱
isle_of_man🇮🇲
india🇮🇳
british_indian_ocean_territory🇮🇴
iraq🇮🇶
iran🇮🇷
iceland🇮🇸
it🇮🇹
jersey🇯🇪
jamaica🇯🇲
jordan🇯🇴
jp🇯🇵
kenya🇰🇪
kyrgyzstan🇰🇬
cambodia🇰🇭
kiribati🇰🇮
comoros🇰🇲
st_kitts_nevis🇰🇳
north_korea🇰🇵
kr🇰🇷
kuwait🇰🇼
cayman_islands🇰🇾
kazakhstan🇰🇿
laos🇱🇦
lebanon🇱🇧
st_lucia🇱🇨
liechtenstein🇱🇮
sri_lanka🇱🇰
liberia🇱🇷
lesotho🇱🇸
lithuania🇱🇹
luxembourg🇱🇺
latvia🇱🇻
libya🇱🇾
morocco🇲🇦
monaco🇲🇨
moldova🇲🇩
montenegro🇲🇪
st_martin🇲🇫
madagascar🇲🇬
marshall_islands🇲🇭
macedonia🇲🇰
mali🇲🇱
myanmar🇲🇲
mongolia🇲🇳
macau🇲🇴
northern_mariana_islands🇲🇵
martinique🇲🇶
mauritania🇲🇷
montserrat🇲🇸
malta🇲🇹
mauritius🇲🇺
maldives🇲🇻
malawi🇲🇼
mexico🇲🇽
malaysia🇲🇾
mozambique🇲🇿
namibia🇳🇦
new_caledonia🇳🇨
niger🇳🇪
norfolk_island🇳🇫
nigeria🇳🇬
nicaragua🇳🇮
netherlands🇳🇱
norway🇳🇴
nepal🇳🇵
nauru🇳🇷
niue🇳🇺
new_zealand🇳🇿
oman🇴🇲
panama🇵🇦
peru🇵🇪
french_polynesia🇵🇫
papua_new_guinea🇵🇬
philippines🇵🇭
pakistan🇵🇰
poland🇵🇱
st_pierre_miquelon🇵🇲
pitcairn_islands🇵🇳
puerto_rico🇵🇷
palestinian_territories🇵🇸
portugal🇵🇹
palau🇵🇼
paraguay🇵🇾
qatar🇶🇦
reunion🇷🇪
romania🇷🇴
serbia🇷🇸
ru🇷🇺
rwanda🇷🇼
saudi_arabia🇸🇦
solomon_islands🇸🇧
seychelles🇸🇨
sudan🇸🇩
sweden🇸🇪
singapore🇸🇬
st_helena🇸🇭
slovenia🇸🇮
svalbard_jan_mayen🇸🇯
slovakia🇸🇰
sierra_leone🇸🇱
san_marino🇸🇲
senegal🇸🇳
somalia🇸🇴
suriname🇸🇷
south_sudan🇸🇸
sao_tome_principe🇸🇹
el_salvador🇸🇻
sint_maarten🇸🇽
syria🇸🇾
swaziland🇸🇿
tristan_da_cunha🇹🇦
turks_caicos_islands🇹🇨
chad🇹🇩
french_southern_territories🇹🇫
togo🇹🇬
thailand🇹🇭
tajikistan🇹🇯
tokelau🇹🇰
timor_leste🇹🇱
turkmenistan🇹🇲
tunisia🇹🇳
tonga🇹🇴
tr🇹🇷
trinidad_tobago🇹🇹
tuvalu🇹🇻
taiwan🇹🇼
tanzania🇹🇿
ukraine🇺🇦
uganda🇺🇬
us_outlying_islands🇺🇲
united_nations🇺🇳
us🇺🇸
uruguay🇺🇾
uzbekistan🇺🇿
vatican_city🇻🇦
st_vincent_grenadines🇻🇨
venezuela🇻🇪
british_virgin_islands🇻🇬
us_virgin_islands🇻🇮
vietnam🇻🇳
vanuatu🇻🇺
wallis_futuna🇼🇫
samoa🇼🇸
kosovo🇽🇰
yemen🇾🇪
mayotte🇾🇹
south_africa🇿🇦
zambia🇿🇲
zimbabwe🇿🇼
england🏴󠁧󠁢󠁥󠁮󠁧󠁿
scotland🏴󠁧󠁢󠁳󠁣󠁴󠁿
wales🏴󠁧󠁢󠁷󠁬󠁳󠁿
diff --git a/docs/examples.md b/docs/examples.md index 6fd2733a..4e936d91 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -4,18 +4,34 @@ There are a million ways to use ntfy, but here are some inspirations. I try to c examples on GitHub, so be sure to check those out, too. -## A long process is done: backups, copying data, pipelines, ... +!!! info + Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please + [create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that + I cannot guarantee that all of these examples are functional. Many of them I have not tried myself. + +## Cronjobs +ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...). + I started adding notifications pretty much all of my scripts. Typically, I just chain the curl call directly to the command I'm running. The following example will either send Laptop backup succeeded or ⚠️ Laptop backup failed directly to my phone: -``` +``` bash rsync -a root@laptop /backups/laptop \ && zfs snapshot ... \ && curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \ || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups ``` +Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with +GitHub have been hopeless. In case it ever becomes available, I want to know immediately. + +``` +# Check github/ntfy user +*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi +``` + + ## Low disk space alerts Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but effective. @@ -37,11 +53,7 @@ if [ -n "$avail" ]; then fi ``` -## Server-sent messages in your web app -Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own -web application. Check out the live example or just look the source of this page. - -## Notify on SSH login +## SSH login alerts Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I own, I now message myself. Here's an example of how to use PAM to notify yourself on SSH login. @@ -89,7 +101,7 @@ It looked something like this: You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated. One of my co-workers uses the following Ansible task to let him know when things are done: -```yml +``` yaml - name: Send ntfy.sh update uri: url: "https://ntfy.sh/{{ ntfy_channel }}" @@ -97,46 +109,83 @@ One of my co-workers uses the following Ansible task to let him know when things body: "{{ inventory_hostname }} reseeding complete" ``` -## Watchtower notifications (shoutrrr) -You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic. +There's also a dedicated Ansible action plugin (one which runs on the Ansible controller) called +[ansible-ntfy](https://github.com/jpmens/ansible-ntfy). The following task posts a message +to ntfy at its default URL (`attrs` and other attributes are optional): + +``` yaml +- name: "Notify ntfy that we're done" + ntfy: + msg: "deployment on {{ inventory_hostname }} is complete. 🐄" + attrs: + tags: [ heavy_check_mark ] + priority: 1 +``` + +## GitHub Actions +You can send a message during a workflow run with curl. Here is an example sending info about the repo, commit and job status. +``` yaml +- name: Actions Ntfy + run: | + curl \ + -u ${{ secrets.NTFY_CRED }} \ + -H "Title: Title here" \ + -H "Content-Type: text/plain" \ + -d $'Repo: ${{ github.repository }}\nCommit: ${{ github.sha }}\nRef: ${{ github.ref }}\nStatus: ${{ job.status}}' \ + ${{ secrets.NTFY_URL }} +``` + +## Changedetection.io +ntfy is an excellent choice for getting notifications when a website has a change sent to your mobile (or desktop), +[changedetection.io](https://changedetection.io) or on GitHub ([dgtlmoon/changedetection.io](https://github.com/dgtlmoon/changedetection.io)) +uses [apprise](https://github.com/caronc/apprise) library for notification integrations. + +To add any ntfy(s) notification to a website change simply add the [ntfy style URL](https://github.com/caronc/apprise/wiki/Notify_ntfy) +to the notification list. + +For example `ntfy://{topic}` or `ntfy://{user}:{password}@{host}:{port}/{topics}` + +In your changedetection.io installation, click `Edit` > `Notifications` on a single website watch (or group) then add +the special ntfy Apprise Notification URL to the Notification List. + +![ntfy alerts on website change](static/img/cdio-setup.jpg) + +## Watchtower (shoutrrr) +You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send +[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. Example docker-compose.yml: -```yml + +``` yaml services: watchtower: image: containrrr/watchtower environment: - WATCHTOWER_NOTIFICATIONS=shoutrrr - - WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates + - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates ``` Or, if you only want to send notifications using shoutrrr: ``` -shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" +shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" ``` -## Random cronjobs -Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with -GitHub have been hopeless. In case it ever becomes available, I want to know immediately. +## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd -``` cron -# Check github/ntfy user -*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi -~ -``` + -## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd) -It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. -Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts). +Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect. + +Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc. +Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts). ## Node-RED You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples: -
Example: Send a message (click to expand) -``` +``` json [ { "id": "c956e688cc74ad8e", @@ -225,7 +274,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
Example: Send a picture (click to expand) -``` +``` json [ { "id": "d135a13eadeb9d6d", @@ -339,10 +388,23 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder ![Node red picture flow](static/img/nodered-picture.png) -## Gatus service health check +## Gatus +To use ntfy with [Gatus](https://github.com/TwiN/gatus), you can use the `ntfy` alerting provider like so: -An example for a custom alert with Gatus +```yaml +alerting: + ntfy: + url: "https://ntfy.sh" + topic: "YOUR_NTFY_TOPIC" + priority: 3 ``` + +For more information on using ntfy with Gatus, refer to [Configuring ntfy alerts](https://github.com/TwiN/gatus#configuring-ntfy-alerts). + +
+ Alternative: Using the custom alerting provider + +```yaml alerting: custom: url: "https://ntfy.sh" @@ -366,3 +428,192 @@ alerting: TRIGGERED: "warning" RESOLVED: "white_check_mark" ``` + +
+ + +## Jellyseerr/Overseerr webhook +Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook +JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click. +And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic. + +``` json +{ + "topic": "requests", + "title": "{{event}}", + "message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}", + "priority": 4, + "attach": "{{image}}", + "click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}" +} +``` + +## Home Assistant +Here is an example for the configuration.yml file to setup a REST notify component. +Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource. + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + data: + topic: YOUR_NTFY_TOPIC + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +If you need to authenticate to your ntfy resource, define the authentication, username and password as below: + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + authentication: basic + username: YOUR_USERNAME + password: YOUR_PASSWORD + data: + topic: YOUR_NTFY_TOPIC + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example: + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + data: + topic: YOUR_NTFY_TOPIC + priority: 4 + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +## Uptime Kuma +Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**. +Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**: + +
+ + +
+ +You can now test the notifications and apply them to monitors: + +
+ + + +
+ +## UptimeRobot +Go to your [UptimeRobot](https://github.com/uptimerobot) My Settings > Alert Contacts > Add Alert Contact +Select **Alert Contact Type** = Webhook. Then set your desired **Friendly Name** (e.g. "ntfy-sh-UP"), **URL to Notify**, **POST value** and select checkbox **Send as JSON (application/json)**. Make sure to send the JSON POST request to ntfy.domain.com without the topic name in the url and include the "topic" name in the JSON body. + +
+ +
+ +``` json +{ + "topic":"myTopic", + "title": "*monitorFriendlyName* *alertTypeFriendlyName*", + "message": "*alertDetails*", + "tags": ["green_circle"], + "priority": 3, + "click": https://uptimerobot.com/dashboard#*monitorID* +} +``` +You can create two Alert Contacts each with a different icon and priority, for example: + +``` json +{ + "topic":"myTopic", + "title": "*monitorFriendlyName* *alertTypeFriendlyName*", + "message": "*alertDetails*", + "tags": ["red_circle"], + "priority": 3, + "click": https://uptimerobot.com/dashboard#*monitorID* +} +``` +You can now add the created Alerts Contact(s) to the monitor(s) and test the notifications: + +
+ +
+ + +## Apprise +ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the +[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)). + +You can use it like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://mytopic +``` + +Or with your own server like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://ntfy.example.com/mytopic +``` + + +## Rundeck +Rundeck by default sends only HTML email which is not processed by ntfy SMTP server. Append following configurations to +[rundeck-config.properties](https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html) : + +``` +# Template +rundeck.mail.template.file=/path/to/template.html +rundeck.mail.template.log.formatted=false +``` + +Example `template.html`: +```html +
Execution ${execution.id} was ${execution.status}
+ +``` + +Add notification on Rundeck (attachment type must be: `Attached as file to email`): +![Rundeck](static/img/rundeck.png) + +## Traccar +This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes + +The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails. + +**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json)) +```xml + https://ntfy.sh + + { + "topic": "{phone}", + "message": "{message}" + } + +``` +If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token +```xml + Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ +``` +or by simply providing traccar with a valid username/password combination. +```xml + phil + mypass +``` diff --git a/docs/faq.md b/docs/faq.md index a1c385e2..6ff97cfe 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,27 +4,35 @@ Who knows. I didn't do a lot of research before making this. It was fun making it. ## Can I use this in my app? Will it stay free? -Yes. As long as you don't abuse it, it'll be available and free of charge. I do not plan on monetizing -the service. +Yes. As long as you don't abuse it, it'll be available and free of charge. While I will always allow usage of the ntfy.sh +server without signup and free of charge, I may also offer paid plans in the future. ## What are the uptime guarantees? -Best effort. +Best effort. + +ntfy currently runs on a single DigitalOcean droplet, without any scale out strategy or redundancies. When the time comes, +I'll add scale out features, but for now it is what it is. + +In the first year of its life, and to this day (Dec'22), ntfy had **no outages** that I can remember. Other than short +blips and some HTTP 500 spikes, it has been rock solid. + +There is a [status page](https://ntfy.statuspage.io/) which is updated based on some automated checks via the amazingly +awesome [healthchecks.io](https://healthchecks.io/) (_no affiliation, just a fan_). ## What happens if there are multiple subscribers to the same topic? -As per usual with pub-sub, all subscribers receive notifications if they are -subscribed to a topic. +As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic. ## Will you know what topics exist, can you spy on me? -If you don't trust me or your messages are sensitive, run your own server. It's open source. -That said, the logs do not contain any topic names or other details about you. -Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome -client network disruptions. +If you don't trust me or your messages are sensitive, run your own server. It's open source. +That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than +troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default) +to facilitate service restarts, message polling and to overcome client network disruptions. ## Can I self-host it? -Yes. The server (including this Web UI) can be self-hosted, and the Android app supports adding topics from +Yes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from your own server as well. Check out the [install instructions](install.md). -## Why is Firebase used? +## Is Firebase used? In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also published to Firebase Cloud Messaging (FCM) (if `FirebaseKeyFile` is set, which it is on ntfy.sh). This is to facilitate notifications on Android. @@ -34,16 +42,63 @@ of the app and [self-host your own ntfy server](install.md). ## How much battery does the Android app use? If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature, -the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, -or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of -battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty -decent now. +the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, +or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)), +the app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone). +There has been a ton of testing and improvement around this. I think it's pretty decent now. + +## Paid plans? I thought it was open source? +All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you +can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher +limits. ## What is instant delivery? [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the -server and listens for incoming notifications. This consumes additional battery, +server and listens for incoming notifications. This consumes additional battery (see above), but delivers notifications instantly. -## Why is there no iOS app (yet)? -I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if -someone else could help out. +## Can you implement feature X? +Yes, maybe. Check out [existing GitHub issues](https://github.com/binwiederhier/ntfy/issues) to see if somebody else had +the same idea before you, or file a new issue. I'll likely get back to you within a few days. + +## I'm having issues with iOS, can you help? The iOS app is behind compared to the Android app, can you fix that? +The iOS is very bare bones and quite frankly a little buggy. I wanted to get something out the door to make the iOS users +happy, but halfway through I got frustrated with iOS development and paused development. I will eventually get back to +it, or hopefully, somebody else will come along and help out. Please review the [known issues](known-issues.md) for details. + +## Can I disable the web app? Can I protect it with a login screen? +The web app is a static website without a backend (other than the ntfy API). All data is stored locally in the browser +cache and local storage. That means it does not need to be protected with a login screen, and it poses no additional +security risk. So technically, it does not need to be disabled. + +However, if you still want to disable it, you can do so with the `web-root: disable` option in the `server.yml` file. + +Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without +a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless. + +## If topic names are public, could I not just brute force them? +If you don't have [ACLs set up](config.md#access-control), the topic name is your password, it says so everywhere. If you +choose a easy-to-guess/dumb topic name, people will be able to guess it. If you choose a randomly generated topic name, +the topic is as good as a good password. + +As for brute forcing: It's not possible to brute force a ntfy server for very long, as you'll get quickly rate limited. +In the default configuration, you'll be able to do 60 requests as a burst, and then 1 request per 10 seconds. Assuming you +choose a random 10 digit topic name using only A-Z, a-z, 0-9, _ and -, there are 64^10 possible topic names. Even if you +could do hundreds of requests per seconds (which you cannot), it would take many years to brute force a topic name. + +For ntfy.sh, there's even a fail2ban in place which will ban your IP pretty quickly. + +## Where can I donate? +I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier). +I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much +appreciated. + +## Can I email you? Can I DM you on Discord/Matrix? +While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org), +[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally +**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a +[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities. + +I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions +in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users +may be able to help out. I hope you understand. diff --git a/docs/hooks.py b/docs/hooks.py new file mode 100644 index 00000000..cdb31a52 --- /dev/null +++ b/docs/hooks.py @@ -0,0 +1,6 @@ +import os +import shutil + +def copy_fonts(config, **kwargs): + site_dir = config['site_dir'] + shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get')) diff --git a/docs/index.md b/docs/index.md index 4213e254..27314f1a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c ## Step 1: Get the app - + To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid. Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just @@ -83,7 +83,7 @@ This will create a notification that looks like this: That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on -[publishing messages](publish.md), as well as the detailed page on the [Android app](subscribe/phone.md). +[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md). Here's another video showing the entire process: diff --git a/docs/install.md b/docs/install.md index a3f8d5f2..c1a621d7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,50 +13,54 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d We support amd64, armv7 and arm64. 1. Install ntfy using one of the methods described below -2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)) -3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml)) +2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)) +3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user), see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml)) To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm). -To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md] +To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md) for details). -## Binaries and packages +If you like tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU) on YouTube, or +[Alex's Docker-based setup guide](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/). Both are great +resources to get started. _I am not affiliated with Kris or Alex, I just liked their video/post._ + +## Linux binaries Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_x86_64.tar.gz - tar zxvf ntfy_1.21.2_linux_x86_64.tar.gz - sudo cp -a ntfy_1.21.2_linux_x86_64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_x86_64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_amd64.tar.gz + tar zxvf ntfy_2.7.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.7.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.tar.gz - tar zxvf ntfy_1.21.2_linux_armv6.tar.gz - sudo cp -a ntfy_1.21.2_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv6.tar.gz + tar zxvf ntfy_2.7.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.7.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.tar.gz - tar zxvf ntfy_1.21.2_linux_armv7.tar.gz - sudo cp -a ntfy_1.21.2_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv7.tar.gz + tar zxvf ntfy_2.7.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.7.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.tar.gz - tar zxvf ntfy_1.21.2_linux_arm64.tar.gz - sudo cp -a ntfy_1.21.2_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_arm64.tar.gz + tar zxvf ntfy_2.7.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.7.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -65,9 +69,10 @@ Installation via Debian repository: === "x86_64/amd64" ```bash - curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add - + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg sudo apt install apt-transport-https - sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' \ + sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ > /etc/apt/sources.list.d/archive.heckel.io.list" sudo apt update sudo apt install ntfy @@ -77,10 +82,11 @@ Installation via Debian repository: === "armv7/armhf" ```bash - curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add - + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg sudo apt install apt-transport-https - sudo sh -c "echo 'deb [arch=armhf] https://archive.heckel.io/apt debian main' \ - > /etc/apt/sources.list.d/archive.heckel.io.list" + sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ + > /etc/apt/sources.list.d/archive.heckel.io.list" sudo apt update sudo apt install ntfy sudo systemctl enable ntfy @@ -89,10 +95,11 @@ Installation via Debian repository: === "arm64" ```bash - curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add - + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg sudo apt install apt-transport-https - sudo sh -c "echo 'deb [arch=arm64] https://archive.heckel.io/apt debian main' \ - > /etc/apt/sources.list.d/archive.heckel.io.list" + sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ + > /etc/apt/sources.list.d/archive.heckel.io.list" sudo apt update sudo apt install ntfy sudo systemctl enable ntfy @@ -103,7 +110,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -111,7 +118,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -119,7 +126,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -127,7 +134,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -137,34 +144,36 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` ## Arch Linux -ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date. +ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). +You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, +build and install ntfy and keep it up to date. ``` paru -S ntfysh-bin ``` @@ -176,6 +185,58 @@ cd ntfysh-bin makepkg -si ``` +## NixOS / Nix +ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment: +``` +nix-env -iA ntfy-sh +``` + +NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy). + +## macOS +The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_darwin_all.tar.gz), +extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). + +If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at +`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). + +```bash +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_darwin_all.tar.gz > ntfy_2.7.0_darwin_all.tar.gz +tar zxvf ntfy_2.7.0_darwin_all.tar.gz +sudo cp -a ntfy_2.7.0_darwin_all/ntfy /usr/local/bin/ntfy +mkdir ~/Library/Application\ Support/ntfy +cp ntfy_2.7.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +ntfy --help +``` + +!!! info + Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for + development as well. Check out the [build instructions](develop.md) for details. + +## Homebrew +To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS), +simply run: +``` +brew install ntfy +``` + + +## Windows +The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_windows_amd64.zip), +extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. + +The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). + +Also available in [Scoop's](https://scoop.sh) Main repository: + +`scoop install ntfy` + +!!! info + There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a + [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. + ## Docker The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should be pretty straight forward to use. @@ -184,6 +245,11 @@ The server exposes its web UI and the API on port 80, so you need to expose that [message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings, you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`. +!!! info + Note that the Docker image **does not contain a `/etc/ntfy/server.yml` file**. If you'd like to use a config file, + please manually create one outside the image and map it as a volume, e.g. via `-v /etc/ntfy:/etc/ntfy`. You may + use the [`server.yml` file on GitHub](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml) as a template. + Basic usage (no cache or additional config): ``` docker run -p 80:80 -it binwiederhier/ntfy serve @@ -196,23 +262,25 @@ docker run \ -p 80:80 \ -it \ binwiederhier/ntfy \ - --cache-file /var/cache/ntfy/cache.db \ - serve + serve \ + --cache-file /var/cache/ntfy/cache.db ``` -With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): +With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): ```bash docker run \ -v /etc/ntfy:/etc/ntfy \ + -e TZ=UTC \ -p 80:80 \ + -u UID:GID \ -it \ binwiederhier/ntfy \ serve ``` -Using docker-compose: +Using docker-compose with non-root user and healthchecks enabled: ```yaml -version: "2.1" +version: "2.3" services: ntfy: @@ -220,14 +288,25 @@ services: container_name: ntfy command: - serve + environment: + - TZ=UTC # optional: set desired timezone + user: UID:GID # optional: replace with your own user/group or uid/gid volumes: - /var/cache/ntfy:/var/cache/ntfy - /etc/ntfy:/etc/ntfy ports: - 80:80 + healthcheck: # optional: remember to adapt the host:port to your environment + test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 40s restart: unless-stopped ``` +If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid. + Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. ``` FROM binwiederhier/ntfy @@ -235,3 +314,300 @@ COPY server.yml /etc/ntfy/server.yml ENTRYPOINT ["ntfy", "serve"] ``` This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port. + +## Kubernetes + +The setup for Kubernetes is very similar to that for Docker, and requires a fairly minimal deployment or pod definition to function. There +are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone +unmanned pod. + + +=== "deployment" + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ntfy + spec: + selector: + matchLabels: + app: ntfy + template: + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve"] + resources: + limits: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + volumes: + - name: config + configMap: + name: ntfy + --- + # Basic service for port 80 + apiVersion: v1 + kind: Service + metadata: + name: ntfy + spec: + selector: + app: ntfy + ports: + - port: 80 + targetPort: 80 + ``` + +=== "stateful set" + ```yaml + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: ntfy + spec: + selector: + matchLabels: + app: ntfy + serviceName: ntfy + template: + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve", "--cache-file", "/var/cache/ntfy/cache.db"] + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + - name: cache + mountPath: "/var/cache/ntfy" + volumes: + - name: config + configMap: + name: ntfy + volumeClaimTemplates: + - metadata: + name: cache + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi + ``` + +=== "pod" + ```yaml + apiVersion: v1 + kind: Pod + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve"] + resources: + limits: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + volumes: + - name: config + configMap: + name: ntfy + ``` + +Configuration is relatively straightforward. As an example, a minimal configuration is provided. + +=== "resource definition" + ```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: ntfy + data: + server.yml: | + # Template: https://github.com/binwiederhier/ntfy/blob/main/server/server.yml + base-url: https://ntfy.sh + ``` + +=== "from-file" + ```bash + kubectl create configmap ntfy --from-file=server.yml + ``` + +## Kustomize + +ntfy can be deployed in a Kubernetes cluster with [Kustomize](https://github.com/kubernetes-sigs/kustomize), a tool used +to customize Kubernetes objects using a `kustomization.yaml` file. + +1. Create new folder - `ntfy` +2. Add all files listed below + 1. `kustomization.yaml` - stores all configmaps and resources used in a deployment + 2. `ntfy-deployment.yaml` - define deployment type and its parameters + 3. `ntfy-pvc.yaml` - describes how [persistent volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) will be created + 4. `ntfy-svc.yaml` - expose application to the internal kubernetes network + 5. `ntfy-ingress.yaml` - expose service to outside the network using [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) + 6. `server.yaml` - simple server configuration +3. Replace **TESTNAMESPACE** within `kustomization.yaml` with designated namespace +4. Replace **ntfy.test** within `ntfy-ingress.yaml` with desired DNS name +5. Apply configuration to cluster set in current context: + +```bash +kubectl apply -k /ntfy +``` + +=== "kustomization.yaml" + ```yaml + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - ntfy-deployment.yaml # deployment definition + - ntfy-svc.yaml # service connecting pods to cluster network + - ntfy-pvc.yaml # pvc used to store cache and attachment + - ntfy-ingress.yaml # ingress definition + configMapGenerator: # will parse config from raw config to configmap,it allows for dynamic reload of application if additional app is deployed ie https://github.com/stakater/Reloader + - name: server-config + files: + - server.yml + namespace: TESTNAMESPACE # select namespace for whole application + ``` +=== "ntfy-deployment.yaml" + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ntfy-deployment + labels: + app: ntfy-deployment + spec: + revisionHistoryLimit: 1 + replicas: 1 + selector: + matchLabels: + app: ntfy-pod + template: + metadata: + labels: + app: ntfy-pod + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy:v1.28.0 # set deployed version + args: ["serve"] + env: #example of adjustments made in environmental variables + - name: TZ # set timezone + value: XXXXXXX + - name: NTFY_DEBUG # enable/disable debug + value: "false" + - name: NTFY_LOG_LEVEL # adjust log level + value: INFO + - name: NTFY_BASE_URL # add base url + value: XXXXXXXXXX + ports: + - containerPort: 80 + name: http-ntfy + resources: + limits: + memory: 300Mi + cpu: 200m + requests: + cpu: 150m + memory: 150Mi + volumeMounts: + - mountPath: /etc/ntfy/server.yml + subPath: server.yml + name: config-volume # generated vie configMapGenerator from kustomization file + - mountPath: /var/cache/ntfy + name: cache-volume #cache volume mounted to persistent volume + volumes: + - name: config-volume + configMap: # uses configmap generator to parse server.yml to configmap + name: server-config + - name: cache-volume + persistentVolumeClaim: # stores /cache/ntfy in defined pv + claimName: ntfy-pvc + ``` + +=== "ntfy-pvc.yaml" + ```yaml + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: ntfy-pvc + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path # adjust storage if needed + resources: + requests: + storage: 1Gi + ``` + +=== "ntfy-svc.yaml" + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: ntfy-svc + spec: + type: ClusterIP + selector: + app: ntfy-pod + ports: + - name: http-ntfy-out + protocol: TCP + port: 80 + targetPort: http-ntfy + ``` + +=== "ntfy-ingress.yaml" + ```yaml + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: ntfy-ingress + spec: + rules: + - host: ntfy.test #select own + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ntfy-svc + port: + number: 80 + ``` + +=== "server.yml" + ```yaml + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 00000000..a8ffa6a9 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,244 @@ +# Integrations + community projects + +There are quite a few projects that work with ntfy, integrate ntfy, or have been built around ntfy. It's super exciting to see what you guys have come up with. Feel free to [create a pull request on GitHub](https://github.com/binwiederhier/ntfy/issues) to add your own project here. + +I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community. + +## Official integrations + +- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs +- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform +- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool +- [Robusta](https://docs.robusta.dev/master/catalog/sinks/webhook.html) ⭐ - open source platform for Kubernetes troubleshooting +- [borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#third-party-monitoring-services) ⭐ - configuration-driven backup software for servers and workstations +- [Radarr](https://radarr.video/) ⭐ - Movie collection manager for Usenet and BitTorrent users +- [Sonarr](https://sonarr.tv/) ⭐ - PVR for Usenet and BitTorrent users +- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard +- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool +- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media +- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends. +- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring +- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool +- [Scrt.link](https://scrt.link/) - Share a secret +- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python +- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier +- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server +- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring +- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification + +## Integration via HTTP/SMTP/etc. + +- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr)) +- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#)) +- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook)) +- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook)) +- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy)) + +## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations + +- [Element](https://f-droid.org/packages/im.vector.app/) ⭐ - Matrix client +- [SchildiChat](https://schildi.chat/android/) ⭐ - Matrix client +- [Tusky](https://tusky.app/) ⭐ - Fediverse client +- [Fedilab](https://fedilab.app/) - Fediverse client +- [FindMyDevice](https://gitlab.com/Nulide/findmydevice/) - Find your Device with an SMS or online with the help of FMDServer +- [Tox Push Message App](https://github.com/zoff99/tox_push_msg_app) - Tox Push Message App + +## Libraries + +- [ntfy-php-library](https://github.com/VerifiedJoseph/ntfy-php-library) - PHP library for sending messages using a ntfy server (PHP) +- [ntfy-notifier](https://github.com/DAcodedBEAT/ntfy-notifier) - Symfony Notifier integration for ntfy (PHP) +- [ntfpy](https://github.com/Nevalicjus/ntfpy) - API Wrapper for ntfy.sh (Python) +- [pyntfy](https://github.com/DP44/pyntfy) - A module for interacting with ntfy notifications (Python) +- [vntfy](https://github.com/lmangani/vntfy) - Barebone V client for ntfy (V) +- [ntfy-middleman](https://github.com/nachotp/ntfy-middleman) - Wraps APIs and send notifications using ntfy.sh on schedule (Python) +- [ntfy-dotnet](https://github.com/nwithan8/ntfy-dotnet) - .NET client library to interact with a ntfy server (C# / .NET) +- [node-ntfy-publish](https://github.com/cityssm/node-ntfy-publish) - A Node package to publish notifications to an ntfy server (Node) +- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R) +- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi) +- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS) +- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart) +- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go) +- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP) +- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java) + +## CLIs + GUIs + +- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events +- [ntfy Desktop client](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy +- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte +- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic +- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop +- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy +- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications + +## Projects + scripts + +- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust) +- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go) +- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js) +- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh) +- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell) +- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell) +- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs) +- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell) +- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP) +- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C) +- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell) +- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell) +- [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python) +- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python) +- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python) +- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript) +- [ntfy Discord bot](https://github.com/binwiederhier/ntfy-bot) - ntfy Discord bot (Go) +- [ntfy Discord bot](https://github.com/jr1221/ntfy_discord_bot) - An advanced modal-based bot for interacting with the ntfy.sh API (Dart) +- [Bettarr Notifications](https://github.com/NiNiyas/Bettarr-Notifications) - Better Notifications for Sonarr and Radarr (Python) +- [Notify me the intruders](https://github.com/nothingbutlucas/notify_me_the_intruders) - Notify you if they are intruders or new connections on your network (Shell) +- [Send GitHub Action to ntfy](https://github.com/NiNiyas/ntfy-action) - Send GitHub Action workflow notifications to ntfy (JS) +- [aTable/ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS) +- [~xenrox/ntfy-alertmanager](https://hub.xenrox.net/~xenrox/ntfy-alertmanager) - A bridge between ntfy and Alertmanager (Go) +- [pinpox/alertmanager-ntfy](https://github.com/pinpox/alertmanager-ntfy) - Relay prometheus alertmanager alerts to ntfy (Go) +- [alexbakker/alertmanager-ntfy](https://github.com/alexbakker/alertmanager-ntfy) - Service that forwards Prometheus Alertmanager notifications to ntfy (Go) +- [restreamchat2ntfy](https://github.com/kurohuku7/restreamchat2ntfy) - Send restream.io chat to ntfy to check on the Meta Quest (JS) +- [k8s-ntfy-deployment-service](https://github.com/Christian42/k8s-ntfy-deployment-service) - Automatic Kubernetes (k8s) ntfy deployment +- [huginn-global-entry-notif](https://github.com/kylezoa/huginn-global-entry-notif) - Checks CBP API for available appointments with Huginn (JSON) +- [ntfyer](https://github.com/KikyTokamuro/ntfyer) - Sending various information to your ntfy topic by time (TypeScript) +- [git-simple-notifier](https://github.com/plamenjm/git-simple-notifier) - Script running git-log, checking for new repositories (Shell) +- [ntfy-to-slack](https://github.com/ozskywalker/ntfy-to-slack) - Tool to subscribe to a ntfy topic and send the messages to a Slack webhook (Go) +- [ansible-ntfy](https://github.com/jpmens/ansible-ntfy) - Ansible action plugin to post JSON messages to ntfy (Python) +- [ntfy-notification-channel](https://github.com/wijourdil/ntfy-notification-channel) - Laravel Notification channel for ntfy (PHP) +- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy +- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust) +- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost +- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go) +- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell) +- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java) +- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python) +- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python) +- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums +- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows +- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog) +- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy +- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy +- [easy2ntfy](https://github.com/chromoxdor/easy2ntfy) - Gateway for ESPeasy to receive commands through ntfy and using easyfetch (HTML/JS) +- [ntfy_lite](https://github.com/MPI-IS/ntfy_lite) - Minimalist python API for pushing ntfy notifications (Python) +- [notify](https://github.com/guanguans/notify) - 推送通知 (PHP) +- [zpool-events](https://github.com/maglar0/zpool-events) - Notify on ZFS pool events (Python) +- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig) +- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript) +- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS) +- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go) +- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash) +- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP) +- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager +- [NtfyMe-Blender](https://github.com/NotNanook/NtfyMe-Blender) - Blender addon to send notifications to NtfyMe (Python) +- [ntfy-ios-url-share](https://www.icloud.com/shortcuts/be8a7f49530c45f79733cfe3e41887e6) - An iOS shortcut that lets you share URLs easily and quickly. +- [ntfy-ios-filesharing](https://www.icloud.com/shortcuts/fe948d151b2e4ae08fb2f9d6b27d680b) - An iOS shortcut that lets you share files from your share feed to a topic of your choice. +- [systemd-ntfy](https://hackage.haskell.org/package/systemd-ntfy) - monitor a set of systemd services an send a notification to ntfy.sh whenever their status changes +- [RouterOS Scripts](https://git.eworm.de/cgit/routeros-scripts/about/) - a collection of scripts for MikroTik RouterOS +- [ntfy-android-builder](https://github.com/TheBlusky/ntfy-android-builder) - Script for building ntfy-android with custom Firebase configuration (Docker/Shell) + +## Blog + forum posts + +- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023 +- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023 +- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023 +- [Supercharge Your Alerts: Ntfy — The Ultimate Push Notification Solution](https://medium.com/spring-boot/supercharge-your-alerts-ntfy-the-ultimate-push-notification-solution-a3dda79651fe) - spring-boot.medium.com - 9/2023 +- [Deploy Ntfy using Docker](https://www.linkedin.com/pulse/deploy-ntfy-mohamed-sharfy/) - linkedin.com - 9/2023 +- [Send Notifications With Ntfy for New WordPress Posts](https://www.activepieces.com/blog/ntfy-notifications-for-wordpress-new-posts) - activepieces.com - 9/2023 +- [Get Ntfy Notifications About New Zendesk Ticket](https://www.activepieces.com/blog/ntfy-notifications-about-new-zendesk-tickets) - activepieces.com - 9/2023 +- [Set reminder for recurring events using ntfy & Cron](https://www.youtube.com/watch?v=J3O4aQ-EcYk) - youtube.com - 9/2023 +- [ntfy - Installation and full configuration setup](https://www.youtube.com/watch?v=QMy14rGmpFI) - youtube.com - 9/2023 +- [How to install Ntfy.sh on Portainer / Docker Compose](https://www.youtube.com/watch?v=utD9GNbAwyg) - youtube.com - 9/2023 +- [ntfy - Push-Benachrichtigungen // Push Notifications](https://www.youtube.com/watch?v=LE3vRPPqZOU) - youtube.com - 9/2023 +- [Podman Update Notifications via Ntfy](https://rair.dev/podman-upadte-notifications-ntfy/) - rair.dev - 9/2023 +- [NetworkChunk - how did I NOT know about this?](https://www.youtube.com/watch?v=poDIT2ruQ9M) ⭐ - youtube.com - 8/2023 +- [NTFY - Command-Line Notifications](https://academy.networkchuck.com/blog/ntfy/) - academy.networkchuck.com - 8/2023 +- [Open Source Push Notifications! Get notified of any event you can imagine. Triggers abound!](https://www.youtube.com/watch?v=WJgwWXt79pE) ⭐ - youtube.com - 8/2023 +- [How to install and self host an Ntfy server on Linux](https://linuxconfig.org/how-to-install-and-self-host-an-ntfy-server-on-linux) - linuxconfig.org - 7/2023 +- [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023 +- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023 +- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023 +- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023 +- [桌面通知:ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023 +- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023 +- [Install ntfy Inside Docker Container in Linux](https://lindevs.com/install-ntfy-inside-docker-container-in-linux) - lindevs.com - 4/2023 +- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023 +- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023 +- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023 +- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023 +- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023 +- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023 +- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023 +- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023 +- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023 +- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023 +- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023 +- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023 +- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023 +- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022 +- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022 +- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022 +- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022 +- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022 +- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022 +- [Enabling SSH Login Notifications using Ntfy](https://paramdeo.com/blog/enabling-ssh-login-notifications-using-ntfy) - paramdeo.com - 11/2022 +- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022 +- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022 +- [How to make my phone buzz*](https://evbogue.com/howtomakemyphonebuzz) - evbogue.com - 11/2022 +- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022 +- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022 +- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022 +- [Envie Push Notifications por POST (de graça e sem cadastro)](https://www.tabnews.com.br/filipedeschamps/envie-push-notifications-por-post-de-graca-e-sem-cadastro) - tabnews.com.br - 11/2022 +- [Push Notifications for KDE](https://volkerkrause.eu/2022/11/12/kde-unifiedpush-push-notifications.html) - volkerkrause.eu - 11/2022 +- [TLDR Newsletter Daily Update 2022-11-09](https://tldr.tech/tech/newsletter/2022-11-09) ⭐ - tldr.tech - 11/2022 +- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022 +- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022 +- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022 +- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022 +- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022 +- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022 +- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022 +- [Neue Services: Ntfy, TikTok und RustDesk](https://adminforge.de/tools/neue-services-ntfy-tiktok-und-rustdesk/) - adminforge.de - 9/2022 +- [Ntfy, le service de notifications qu’il vous faut](https://www.cachem.fr/ntfy-le-service-de-notifications-quil-vous-faut/) - cachem.fr - 9/2022 +- [NAS Synology et notifications avec ntfy](https://www.cachem.fr/synology-notifications-ntfy/) - cachem.fr - 9/2022 +- [Self hosted Mobile Push Notifications using NTFY | Thejesh GN](https://thejeshgn.com/2022/08/23/self-hosted-mobile-push-notifications-using-ntfy/) - thejeshgn.com - 8/2022 +- [Fedora Magazine | 4 cool new projects to try in Copr](https://fedoramagazine.org/4-cool-new-projects-to-try-in-copr-for-august-2022/) - fedoramagazine.org - 8/2022 +- [Docker로 오픈소스 푸시알람 프로젝트 ntfy.sh 설치 및 사용하기.(Feat. Uptimekuma)](https://svrforum.com/svr/398979) - svrforum.com - 8/2022 +- [Easy notifications from R](https://sometimesir.com/posts/easy-notifications-from-r/) - sometimesir.com - 6/2022 +- [ntfy is finally coming to iOS, and Matrix/UnifiedPush gateway support](https://www.reddit.com/r/selfhosted/comments/vdzvxi/ntfy_is_finally_coming_to_ios_with_full/) ⭐ - reddit.com - 6/2022 +- [Install guide (with Docker)](https://chowdera.com/2022/150/202205301257379077.html) - chowdera.com - 5/2022 +- [无需注册的通知服务ntfy](https://blog.csdn.net/wbsu2004/article/details/125040247) - blog.csdn.net - 5/2022 +- [Updated review post (Jan-Lukas Else)](https://jlelse.blog/thoughts/2022/04/ntfy) - jlelse.blog - 4/2022 +- [Using ntfy and Tasker together](https://lachlanlife.net/posts/2022-04-tasker-ntfy/) - lachlanlife.net - 4/2022 +- [Reddit feature update post](https://www.reddit.com/r/selfhosted/comments/uetlso/ntfy_is_a_tool_to_send_push_notifications_to_your/) ⭐ - reddit.com - 4/2022 +- [無料で簡単に通知の送受信ができつつオープンソースでセルフホストも可能な「ntfy」を使ってみた](https://gigazine.net/news/20220404-ntfy-push-notification/) - gigazine.net - 4/2022 +- [Pocketmags ntfy review](https://pocketmags.com/us/linux-format-magazine/march-2022/articles/1104187/ntfy) - pocketmags.com - 3/2022 +- [Reddit web app release post](https://www.reddit.com/r/selfhosted/comments/tc0p0u/say_hello_to_the_brand_new_ntfysh_web_app_push/) ⭐ - reddit.com- 3/2022 +- [Lemmy post (Jakob)](https://lemmy.eus/post/15541) - lemmy.eus - 1/2022 +- [Reddit UnifiedPush release post](https://www.reddit.com/r/selfhosted/comments/s5jylf/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 1/2022 +- [ntfy: send notifications from your computer to your phone](https://rs1.es/tutorials/2022/01/19/ntfy-send-notifications-phone.html) - rs1.es - 1/2022 +- [Short ntfy review (Jan-Lukas Else)](https://jlelse.blog/links/2021/12/ntfy-sh) - jlelse.blog - 12/2021 +- [Free MacroDroid webhook alternative (FrameXX)](https://www.macrodroidforum.com/index.php?threads/ntfy-sh-free-macrodroid-webhook-alternative.1505/) - macrodroidforum.com - 12/2021 +- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021 +- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021 +- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021 + + +## Alternative ntfy servers + +Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the +ntfy community. Thanks to everyone running a public server. **You guys rock!** + +| URL | Country | +|---------------------------------------------------|--------------------| +| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States | +| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France | +| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | +| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | +| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | +| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany | +| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France | +| [ntfy.fossman.de](https://ntfy.fossman.de/) | 🇩🇪 Germany | + +Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability +and uptime of third party servers, so use of each server is **at your own discretion**. diff --git a/docs/known-issues.md b/docs/known-issues.md new file mode 100644 index 00000000..cdb95bb6 --- /dev/null +++ b/docs/known-issues.md @@ -0,0 +1,43 @@ +# Known issues +This is an incomplete list of known issues with the ntfy server, web app, Android app, and iOS app. You can find a complete +list [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful +to have the prominent ones here to link to. + +## iOS app not refreshing (see [#267](https://github.com/binwiederhier/ntfy/issues/267)) +For some (many?) users, the iOS app is not refreshing the view when new notifications come in. Until you manually +swipe down, you do not see the newly arrived messages, even though the popup appeared before. + +This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely +clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it. + +Please send experienced iOS developers my way to help me figure this out. + +## iOS app not receiving notifications (anymore) +If notifications do not show up at all anymore, there are a few causes for it (that I know of): + +**Firebase+APNS are being weird and buggy**: +If this is the case, usually it helps to **remove the topic/subscription and re-add it**. That will force Firebase to +re-subscribe to the Firebase topic. + +**Self-hosted only: No `upstream-base-url` set, or `base-url` mismatch**: +To make self-hosted servers work with the iOS +app, I had to do some horrible things (see [iOS instant notifications](config.md#ios-instant-notifications) for details). +Be sure that in your selfhosted server: + +* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**) +* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to + +## iOS app seeing "New message", but not real message content +If you see `New message` notifications on iOS, your iPhone can likely not talk to your self-hosted server. Be sure that +your iOS device and your ntfy server are either on the same network, or that your phone can actually reach the server. + +Turn on tracing/debugging on the server (via `log-level: trace` or `log-level: debug`, see [troubleshooting](troubleshooting.md)), +and read docs on [iOS instant notifications](https://docs.ntfy.sh/config/#ios-instant-notifications). + +## Safari does not play sounds for web push notifications +Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with +iOS 17 / Safari 17, which will be released later in 2023. + +## PWA on iOS sometimes crashes with an IndexedDB error (see [#787](https://github.com/binwiederhier/ntfy/issues/787)) +When resuming the installed PWA from the background, it sometimes crashes with an error from IndexedDB/Dexie, due to a +[WebKit bug]( https://bugs.webkit.org/show_bug.cgi?id=197050). A reload will fix it until a permanent fix is found. diff --git a/docs/privacy.md b/docs/privacy.md index 5a36a1c8..f89f9aaa 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see [FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version. -The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages, -aside from a short on-disk cache to support service restarts. +For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics +or messages, though typically this is turned off. diff --git a/docs/publish.md b/docs/publish.md index c9971fa0..41370778 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -38,7 +38,12 @@ Here's an example showing how to publish a simple message using a POST request: === "PowerShell" ``` powershell - Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Body = "Backup successful" + } + Invoke-RestMethod @Request ``` === "Python" @@ -124,12 +129,17 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/phil_alerts" - $headers = @{ Title="Unauthorized access detected" - Priority="urgent" - Tags="warning,skull" } - $body = "Remote access to phils-laptop detected. Act right away." - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/phil_alerts" + Headers = @{ + Title = "Unauthorized access detected" + Priority = "urgent" + Tags = "warning,skull" + } + Body = "Remote access to phils-laptop detected. Act right away." + } + Invoke-RestMethod @Request ``` === "Python" @@ -242,18 +252,21 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](# === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/mydoorbell" - $headers = @{ Click="https://home.nest.com/" - Attach="https://nest.com/view/yAxkasd.jpg" - Actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true" - Email="phil@example.com" } - $body = @' - There's someone at the door. 🐶 - - Please check if it's a good boy or a hooman. - Doggies have been known to ring the doorbell. - '@ - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mydoorbell" + Headers = @{ + Click = "https://home.nest.com" + Attach = "https://nest.com/view/yAxksd.jpg" + Actions = "http, Open door, https://api.nest.com/open/yAxkasd, clear=true" + Email = "phil@example.com" + } + Body = "There's someone at the door. 🐶`n + `n + Please check if it's a good boy or a hooman.`n + Doggies have been known to ring the doorbell.`n" + } + Invoke-RestMethod @Request ``` === "Python" @@ -296,6 +309,8 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](# ## Message title +_Supported on:_ :material-android: :material-apple: :material-firefox: + The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title, you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). @@ -340,10 +355,15 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/controversial" - $headers = @{ Title="Dogs are better than cats" } - $body = "Oh my ..." - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/controversial" + Headers = @{ + Title = "Dogs are better than cats" + } + Body = "Oh my ..." + } + Invoke-RestMethod @Request ``` === "Python" @@ -371,8 +391,16 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
Detail view of notification with title
+!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + ## Message priority -All messages have a priority, which defines how urgently your phone notifies you. You can set custom +_Supported on:_ :material-android: :material-apple: :material-firefox: + +All messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)). The following priorities exist: @@ -428,10 +456,15 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/phil_alerts" - $headers = @{ Priority="5" } - $body = "An urgent message" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = 'POST' + URI = "https://ntfy.sh/phil_alerts" + Headers = @{ + Priority = "5" + } + Body = "An urgent message" + } + Invoke-RestMethod @Request ``` === "Python" @@ -460,6 +493,8 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P ## Tags & emojis 🥳 🎉 +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can tag messages with emojis and other relevant strings: * **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended @@ -547,10 +582,15 @@ them with a comma, e.g. `tag1,tag2,tag3`. === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/backups" - $headers = @{ Tags="warning,mailsrv13,daily-backup" } - $body = "Backup of mailsrv13 failed" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/backups" + Headers = @{ + Tags = "warning,mailsrv13,daily-backup" + } + Body = "Backup of mailsrv13 failed" + } + Invoke-RestMethod @Request ``` === "Python" @@ -578,7 +618,118 @@ them with a comma, e.g. `tag1,tag2,tag3`.
Detail view of notifications with tags
+!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +## Markdown formatting +_Supported on:_ :material-firefox: + +You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use +**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now): + +- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`) +- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`) +- [Images](https://www.markdownguide.org/basic-syntax/#images) (`![some image](https://bing.com/logo.png)`) +- [Code blocks](https://www.markdownguide.org/basic-syntax/#code-blocks) (` ```code blocks``` `) and [inline code](https://www.markdownguide.org/basic-syntax/#inline-code) (`` `inline code` ``) +- [Headings](https://www.markdownguide.org/basic-syntax/#headings) (`# headings`, `## headings`, etc.) +- [Lists](https://www.markdownguide.org/basic-syntax/#lists) (`- lists`, `1. lists`, etc.) +- [Blockquotes](https://www.markdownguide.org/basic-syntax/#blockquotes) (`> blockquotes`) +- [Horizontal rules](https://www.markdownguide.org/basic-syntax/#horizontal-rules) (`---`) + +By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of +its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`. +As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting: + +=== "Command line (curl)" + ``` + curl \ + -d "Look ma, **bold text**, *italics*, ..." \ + -H "Markdown: yes" \ + ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --markdown \ + mytopic \ + "Look ma, **bold text**, *italics*, ..." + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + Markdown: yes + + Look ma, **bold text**, *italics*, ... + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', // PUT works too + body: 'Look ma, **bold text**, *italics*, ...', + headers: { 'Markdown': 'yes' } + }) + ``` + +=== "Go" + ``` go + http.Post("https://ntfy.sh/mytopic", "text/markdown", + strings.NewReader("Look ma, **bold text**, *italics*, ...")) + + // or + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Look ma, **bold text**, *italics*, ...")) + req.Header.Set("Markdown", "yes") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Body = "Look ma, **bold text**, *italics*, ..." + Headers = @{ + Markdown = "yes" + } + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="Look ma, **bold text**, *italics*, ..." + headers={ "Markdown": "yes" })) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/markdown', // ! + 'content' => 'Look ma, **bold text**, *italics*, ...' + ] + ])); + ``` + +Here's what that looks like in the web app: + +
+ ![markdown](static/img/web-markdown.png){ width=500 } +
Markdown formatting in the web app
+
+ ## Scheduled delivery +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself reminders or even to execute commands at a later date (if your subscriber acts on messages). @@ -637,10 +788,15 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/hello" - $headers = @{ At="tomorrow, 10am" } - $body = "Good morning" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/hello" + Headers = @{ + At = "tomorrow, 10am" + } + Body = "Good morning" + } + Invoke-RestMethod @Request ``` === "Python" @@ -679,6 +835,8 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
## Webhooks (publish via GET) +_Supported on:_ :material-android: :material-apple: :material-firefox: + In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g. like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app). @@ -719,7 +877,7 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo === "PowerShell" ``` powershell - Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger" + Invoke-RestMethod "ntfy.sh/mywebhook/trigger" ``` === "Python" @@ -768,7 +926,7 @@ Here's an example with a custom message, tags and a priority: === "PowerShell" ``` powershell - Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" ``` === "Python" @@ -782,6 +940,8 @@ Here's an example with a custom message, tags and a priority: ``` ## Publish as JSON +_Supported on:_ :material-android: :material-apple: :material-firefox: + For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message as JSON in the request body. @@ -871,19 +1031,29 @@ is the only required one: === "PowerShell" ``` powershell - $uri = "https://ntfy.sh" - $body = @{ - "topic"="powershell" - "title"="Low disk space alert" - "message"="Disk space is low at 5.1 GB" - "priority"=4 - "attach"="https://filesrv.lan/space.jpg" - "filename"="diskspace.jpg" - "tags"=@("warning","cd") - "click"= "https://homecamera.lan/xasds1h2xsSsa/" - "actions"=@[@{ "action"="view", "label"="Admin panel", "url"="https://filesrv.lan/admin" }] - } | ConvertTo-Json - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "mytopic" + Title = "Low disk space alert" + Message = "Disk space is low at 5.1 GB" + Priority = 4 + Attach = "https://filesrv.lan/space.jpg" + FileName = "diskspace.jpg" + Tags = @("warning", "cd") + Click = "https://homecamera.lan/xasds1h2xsSsa/" + Actions = @( + @{ + Action = "view" + Label = "Admin panel" + URL = "https://filesrv.lan/admin" + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request ``` === "Python" @@ -938,11 +1108,16 @@ all the supported fields: | `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | | `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) | +| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | +| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | | `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | +| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | ## Action buttons +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly useful and has countless applications. @@ -953,10 +1128,10 @@ As of today, the following actions are supported: * [`view`](#open-websiteapp): Opens a website or app when the action button is tapped * [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent - when the action button is tapped + when the action button is tapped (only supported on Android) * [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped -Here's an example of what that a notification with actions can look like: +Here's an example of what a notification with actions can look like:
![notification with actions](static/img/android-screenshot-notification-actions.png){ width=500 } @@ -1041,10 +1216,15 @@ As an example, here's how you can create the above notification using this forma === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/myhome" - $headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" } - $body = "You left the house. Turn down the A/C?" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" + } + Body = "You left the house. Turn down the A/C?" + } + Invoke-RestMethod @Request ``` === "Python" @@ -1066,7 +1246,13 @@ As an example, here's how you can create the above notification using this forma ] ])); ``` - + +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + #### Using a JSON array Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body (see [publish as JSON](#publish-as-json)): @@ -1146,7 +1332,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific method: 'POST', body: JSON.stringify({ topic: "myhome", - message": "You left the house. Turn down the A/C?", + message: "You left the house. Turn down the A/C?", actions: [ { action: "view", @@ -1194,26 +1380,30 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific === "PowerShell" ``` powershell - $uri = "https://ntfy.sh" - $body = @{ - "topic"="myhome" - "message"="You left the house. Turn down the A/C?" - "actions"=@( - @{ - "action"="view" - "label"="Open portal" - "url"="https://home.nest.com/" - "clear"=true - }, - @{ - "action"="http", - "label"="Turn down" - "url"="https://api.nest.com/" - "body"="{\"temperature\": 65}" - } + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "myhome" + Message = "You left the house. Turn down the A/C?" + Actions = @( + @{ + Action = "view" + Label = "Open portal" + URL = "https://home.nest.com/" + Clear = $true + }, + @{ + Action = "http" + Label = "Turn down" + URL = "https://api.nest.com/" + Body = '{"temperature": 65}' + } ) - } | ConvertTo-Json - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing + } + ContentType = "application/json" + } + Invoke-RestMethod @Request ``` === "Python" @@ -1272,10 +1462,12 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific ``` The required/optional fields for each action depend on the type of the action itself. Please refer to -[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request) +[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request) for details. ### Open website/app +_Supported on:_ :material-android: :material-apple: :material-firefox: + The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your desktop browser treat the links. Normally it'll just open a link in the browser. @@ -1294,7 +1486,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "Command line (curl)" ``` curl \ - -d "Somebody retweetet your tweet." \ + -d "Somebody retweeted your tweet." \ -H "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \ ntfy.sh/myhome ``` @@ -1304,7 +1496,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): ntfy publish \ --actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \ myhome \ - "Somebody retweetet your tweet." + "Somebody retweeted your tweet." ``` === "HTTP" @@ -1313,14 +1505,14 @@ Here's an example using the [`X-Actions` header](#using-a-header): Host: ntfy.sh Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392 - Somebody retweetet your tweet. + Somebody retweeted your tweet. ``` === "JavaScript" ``` javascript fetch('https://ntfy.sh/myhome', { method: 'POST', - body: 'Somebody retweetet your tweet.', + body: 'Somebody retweeted your tweet.', headers: { 'Actions': 'view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392' } @@ -1329,23 +1521,28 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "Go" ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Somebody retweetet your tweet.")) + req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Somebody retweeted your tweet.")) req.Header.Set("Actions", "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392") http.DefaultClient.Do(req) ``` === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/myhome" - $headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" } - $body = "Somebody retweetet your tweet." - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions = "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" + } + Body = "Somebody retweeted your tweet." + } + Invoke-RestMethod @Request ``` === "Python" ``` python requests.post("https://ntfy.sh/myhome", - data="Somebody retweetet your tweet.", + data="Somebody retweeted your tweet.", headers={ "Actions": "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }) ``` @@ -1357,7 +1554,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): 'header' => "Content-Type: text/plain\r\n" . "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392", - 'content' => 'Somebody retweetet your tweet.' + 'content' => 'Somebody retweeted your tweet.' ] ])); ``` @@ -1369,7 +1566,7 @@ And the same example using [JSON publishing](#publish-as-json): curl ntfy.sh \ -d '{ "topic": "myhome", - "message": "Somebody retweetet your tweet.", + "message": "Somebody retweeted your tweet.", "actions": [ { "action": "view", @@ -1391,7 +1588,7 @@ And the same example using [JSON publishing](#publish-as-json): } ]' \ myhome \ - "Somebody retweetet your tweet." + "Somebody retweeted your tweet." ``` === "HTTP" @@ -1401,7 +1598,7 @@ And the same example using [JSON publishing](#publish-as-json): { "topic": "myhome", - "message": "Somebody retweetet your tweet.", + "message": "Somebody retweeted your tweet.", "actions": [ { "action": "view", @@ -1418,7 +1615,7 @@ And the same example using [JSON publishing](#publish-as-json): method: 'POST', body: JSON.stringify({ topic: "myhome", - message": "Somebody retweetet your tweet.", + message": "Somebody retweeted your tweet.", actions: [ { action: "view", @@ -1437,7 +1634,7 @@ And the same example using [JSON publishing](#publish-as-json): body := `{ "topic": "myhome", - "message": "Somebody retweetet your tweet.", + "message": "Somebody retweeted your tweet.", "actions": [ { "action": "view", @@ -1452,19 +1649,23 @@ And the same example using [JSON publishing](#publish-as-json): === "PowerShell" ``` powershell - $uri = "https://ntfy.sh" - $body = @{ - "topic"="myhome" - "message"="Somebody retweetet your tweet." - "actions"=@( - @{ - "action"="view" - "label"="Open Twitter" - "url"="https://twitter.com/binwiederhier/status/1467633927951163392" - } + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "myhome" + Message = "Somebody retweeted your tweet." + Actions = @( + @{ + Action = "view" + Label = "Open Twitter" + URL = "https://twitter.com/binwiederhier/status/1467633927951163392" + } ) - } | ConvertTo-Json - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing + } + ContentType = "application/json" + } + Invoke-RestMethod @Request ``` === "Python" @@ -1472,7 +1673,7 @@ And the same example using [JSON publishing](#publish-as-json): requests.post("https://ntfy.sh/", data=json.dumps({ "topic": "myhome", - "message": "Somebody retweetet your tweet.", + "message": "Somebody retweeted your tweet.", "actions": [ { "action": "view", @@ -1492,7 +1693,7 @@ And the same example using [JSON publishing](#publish-as-json): 'header' => "Content-Type: application/json", 'content' => json_encode([ "topic": "myhome", - "message": "Somebody retweetet your tweet.", + "message": "Somebody retweeted your tweet.", "actions": [ [ "action": "view", @@ -1515,6 +1716,8 @@ The `view` action supports the following fields: | `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped | ### Send Android broadcast +_Supported on:_ :material-android: + The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means @@ -1576,10 +1779,15 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/wifey" - $headers = @{ Actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" } - $body = "Your wife requested you send a picture of yourself." - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/wifey" + Headers = @{ + Actions = "broadcast, Take picture, extras.cmd=pic, extras.camera=front" + } + Body = "Your wife requested you send a picture of yourself." + } + Invoke-RestMethod @Request ``` === "Python" @@ -1707,22 +1915,28 @@ And the same example using [JSON publishing](#publish-as-json): === "PowerShell" ``` powershell - $uri = "https://ntfy.sh" - $body = @{ - "topic"="wifey" - "message"="Your wife requested you send a picture of yourself." - "actions"=@( - @{ - "action"="broadcast" - "label"="Take picture" - "extras"=@{ - "cmd"="pic" - "camera"="front" - } + # Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras', + # otherwise it will read System.Collections.Hashtable in the returned JSON + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-Json -Depth 3 @{ + Topic = "wifey" + Message = "Your wife requested you send a picture of yourself." + Actions = @( + @{ + Action = "broadcast" + Label = "Take picture" + Extras = @{ + CMD ="pic" + Camera = "front" } + } ) - } | ConvertTo-Json - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing + } + ContentType = "application/json" + } + Invoke-RestMethod @Request ``` === "Python" @@ -1779,6 +1993,8 @@ The `broadcast` action supports the following fields: | `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped | ### Send HTTP request +_Supported on:_ :material-android: :material-apple: :material-firefox: + The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs for whatever systems you have, e.g. opening the garage door, or turning on/off lights. @@ -1791,14 +2007,14 @@ Here's an example using the [`X-Actions` header](#using-a-header): ``` curl \ -d "Garage door has been open for 15 minutes. Close it?" \ - -H "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ + -H "Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ ntfy.sh/myhome ``` === "ntfy CLI" ``` ntfy publish \ - --actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ + --actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ myhome \ "Garage door has been open for 15 minutes. Close it?" ``` @@ -1807,7 +2023,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): ``` http POST /myhome HTTP/1.1 Host: ntfy.sh - Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"} + Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"} Garage door has been open for 15 minutes. Close it? ``` @@ -1818,7 +2034,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): method: 'POST', body: 'Garage door has been open for 15 minutes. Close it?', headers: { - 'Actions': 'http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}' + 'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}' } }) ``` @@ -1826,23 +2042,28 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "Go" ``` go req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?")) - req.Header.Set("Actions", "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}") + req.Header.Set("Actions", "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}") http.DefaultClient.Do(req) ``` === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/myhome" - $headers = @{ Actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" } - $body = "Garage door has been open for 15 minutes. Close it?" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" + } + Body = "Garage door has been open for 15 minutes. Close it?" + } + Invoke-RestMethod @Request ``` === "Python" ``` python requests.post("https://ntfy.sh/myhome", data="Garage door has been open for 15 minutes. Close it?", - headers={ "Actions": "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }) + headers={ "Actions": "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }) ``` === "PHP" @@ -1852,7 +2073,7 @@ Here's an example using the [`X-Actions` header](#using-a-header): 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n" . - "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}", + 'Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}', 'content' => 'Garage door has been open for 15 minutes. Close it?' ] ])); @@ -1973,25 +2194,31 @@ And the same example using [JSON publishing](#publish-as-json): === "PowerShell" ``` powershell - $uri = "https://ntfy.sh" - $body = @{ - "topic"="myhome" - "message"="Garage door has been open for 15 minutes. Close it?" - "actions"=@( - @{ - "action"="http", - "label"="Close door" - "url"="https://api.mygarage.lan/" - "method"="PUT" - "headers"=@{ - "Authorization"="Bearer zAzsx1sk.." - } - "body"="{\"action\": \"close\"}" + # Powershell requires the 'Depth' argument to equal 3 here to expand 'headers', + # otherwise it will read System.Collections.Hashtable in the returned JSON + + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-Json -Depth 3 @{ + Topic = "myhome" + Message = "Garage door has been open for 15 minutes. Close it?" + Actions = @( + @{ + Action = "http" + Label = "Close door" + URL = "https://api.mygarage.lan/" + Method = "PUT" + Headers = @{ + Authorization = "Bearer zAzsx1sk.." } + Body = ConvertTo-JSON @{Action = "close"} } ) - } | ConvertTo-Json - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing + } + ContentType = "application/json" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2055,11 +2282,13 @@ The `http` action supports the following fields: | `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. | ## Click action +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can define which URL to open when a notification is clicked. This may be useful if your notification is related to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open the web browser (or the app) and open the website. -To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its aliase `Click`). +To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`). If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled by another app, the responsible app may open. @@ -2116,10 +2345,13 @@ Here's an example that will open Reddit when the notification is clicked: === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/reddit_alerts" - $headers = @{ Click="https://www.reddit.com/message/messages" } - $body = "New messages on Reddit" - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/reddit_alerts" + Headers = @{ Click="https://www.reddit.com/message/messages" } + Body = "New messages on Reddit" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2143,6 +2375,8 @@ Here's an example that will open Reddit when the notification is clicked: ``` ## Attachments +_Supported on:_ :material-android: :material-firefox: + You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. @@ -2185,8 +2419,8 @@ Here's an example showing how to upload an image: Host: ntfy.sh Filename: flower.jpg Content-Type: 52312 - - + + (binary JPEG data) ``` === "JavaScript" @@ -2286,9 +2520,12 @@ Here's an example showing how to attach an APK file: === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/mydownloads" - $headers = @{ Attach="https://f-droid.org/F-Droid.apk" } - Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mydownloads" + Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } + } + Invoke-RestMethod @Request ``` === "Python" @@ -2314,7 +2551,120 @@ Here's an example showing how to attach an APK file:
File attachment sent from an external URL
+## Icons +_Supported on:_ :material-android: + +You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query +parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download +the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are +cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**. + +Here's an example showing how to include an icon: + +=== "Command line (curl)" + ``` + curl \ + -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + -H "Title: Kodi: Resuming Playback" \ + -H "Tags: arrow_forward" \ + -d "The Wire, S01E01" \ + ntfy.sh/tvshows + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + --title="Kodi: Resuming Playback" \ + --tags="arrow_forward" \ + tvshows \ + "The Wire, S01E01" + ``` + +=== "HTTP" + ``` http + POST /tvshows HTTP/1.1 + Host: ntfy.sh + Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png + Tags: arrow_forward + Title: Kodi: Resuming Playback + + The Wire, S01E01 + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/tvshows', { + method: 'POST', + headers: { + 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png', + 'Title': 'Kodi: Resuming Playback', + 'Tags': 'arrow_forward' + }, + body: "The Wire, S01E01" + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01")) + req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png") + req.Header.Set("Tags", "arrow_forward") + req.Header.Set("Title", "Kodi: Resuming Playback") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/tvshows" + Headers = @{ + Title = "Kodi: Resuming Playback" + Tags = "arrow_forward" + Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + } + Body = "The Wire, S01E01" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/tvshows", + data="The Wire, S01E01", + headers={ + "Title": "Kodi: Resuming Playback", + "Tags": "arrow_forward", + "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Title: Kodi: Resuming Playback\r\n" . + "Tags: arrow_forward\r\n" . + "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png", + ], + 'content' => "The Wire, S01E01" + ])); + ``` + +Here's an example of how it will look on Android: + +
+ ![file attachment](static/img/android-screenshot-icon.png){ width=500 } +
Custom icon from an external URL
+
+ ## E-mail notifications +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that you'd like to persist longer, or to blast-notify yourself on all possible channels. @@ -2382,13 +2732,18 @@ that, your IP address appears in the e-mail body. This is to prevent abuse. === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/alerts" - $headers = @{ Title"="Low disk space alert" - Priority="high" - Tags="warning,skull,backup-host,ssh-login") - Email="phil@example.com" } - $body = "Unknown login from 5.31.23.83 to backups.example.com" - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Title = "Low disk space alert" + Priority = "high" + Tags = "warning,skull,backup-host,ssh-login") + Email = "phil@example.com" + } + Body = "Unknown login from 5.31.23.83 to backups.example.com" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2425,6 +2780,8 @@ Here's what that looks like in Google Mail: ## E-mail publishing +_Supported on:_ :material-android: :material-apple: :material-firefox: + You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls). @@ -2437,6 +2794,11 @@ format is: ntfy-$topic@ntfy.sh ``` +If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to +``` +ntfy-$topic+$token@ntfy.sh +``` + As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority, delay and other features are not supported (yet). Here's an example that will publish a message with the title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)): @@ -2446,21 +2808,154 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
-## Advanced features +## Phone calls +_Supported on:_ :material-android: :material-apple: :material-firefox: -### Authentication -Depending on whether the server is configured to support [access control](config.md#access-control), some topics -may be read/write protected so that only users with the correct credentials can subscribe or publish to them. -To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) -with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing -your password. +You can use ntfy to call a phone and **read the message out loud using text-to-speech**. +Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have +the ntfy app installed on their phone. -Here's a simple example: +**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is +**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone +number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. +You may also simply pass `yes` as a value to pick the first of your verified phone numbers. +On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. + +
+ ![phone number verification](static/img/web-phone-verify.png) +
Phone number verification in the web app
+
+ +As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll +be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). + +!!! info + You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**. + This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or + violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated. + +Here's how you use it: === "Command line (curl)" ``` curl \ - -u phil:mypass \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + -H "Call: +12223334444" \ + -d "Your garage seems to be on fire. You should probably check that out." \ + ntfy.sh/alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + --call=+12223334444 \ + alerts "Your garage seems to be on fire. You should probably check that out." + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 + Call: +12223334444 + + Your garage seems to be on fire. You should probably check that out. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "Your garage seems to be on fire. You should probably check that out.", + headers: { + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'Call': '+12223334444' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", + strings.NewReader("Your garage seems to be on fire. You should probably check that out.")) + req.Header.Set("Call", "+12223334444") + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Call = "+12223334444" + } + Body = "Your garage seems to be on fire. You should probably check that out." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="Your garage seems to be on fire. You should probably check that out.", + headers={ + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", + "Call": "+12223334444" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . + "Call: +12223334444", + 'content' => 'Your garage seems to be on fire. You should probably check that out.' + ] + ])); + ``` + +Here's what a phone call from ntfy sounds like: + + + +Audio transcript: + +> You have a notification from ntfy on topic alerts. +> Message: Your garage seems to be on fire. You should probably check that out. End message. +> This message was sent by user phil. It will be repeated up to three times. + +## Authentication +Depending on whether the server is configured to support [access control](config.md#access-control), some topics +may be read/write protected so that only users with the correct credentials can subscribe or publish to them. +To publish/subscribe to protected topics, you can: + +* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk` +* Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2` +* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw` + +!!! warning + When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your + self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password. + +### Username + password +The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication). +Here's an example with a user `testuser` and password `fakepassword`: + +=== "Command line (curl)" + ``` + curl \ + -u testuser:fakepassword \ -d "Look ma, with auth" \ https://ntfy.example.com/mysecrets ``` @@ -2468,7 +2963,7 @@ Here's a simple example: === "ntfy CLI" ``` ntfy publish \ - -u phil:mypass \ + -u testuser:fakepassword \ ntfy.example.com/mysecrets \ "Look ma, with auth" ``` @@ -2477,7 +2972,7 @@ Here's a simple example: ``` http POST /mysecrets HTTP/1.1 Host: ntfy.example.com - Authorization: Basic cGhpbDpteXBhc3M= + Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk Look ma, with auth ``` @@ -2488,7 +2983,7 @@ Here's a simple example: method: 'POST', // PUT works too body: 'Look ma, with auth', headers: { - 'Authorization': 'Basic cGhpbDpteXBhc3M=' + 'Authorization': 'Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk' } }) ``` @@ -2497,16 +2992,41 @@ Here's a simple example: ``` go req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", strings.NewReader("Look ma, with auth")) - req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=") + req.Header.Set("Authorization", "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk") http.DefaultClient.Do(req) ``` -=== "PowerShell" +=== "PowerShell 7+" ``` powershell - $uri = "https://ntfy.example.com/mysecrets" - $headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" } - $body = "Look ma, with auth" - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing + # Get the credentials from the user + $Credential = Get-Credential testuser + + # Alternatively, create a PSCredential object with the password from scratch + $Credential = [PSCredential]::new("testuser", (ConvertTo-SecureString "password" -AsPlainText -Force)) + + # Note that the Authentication parameter requires PowerShell 7 or later + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Authentication = "Basic" + Credential = $Credential + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "PowerShell 5 and earlier" + ``` powershell + # With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves + $CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)" + $EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString)) + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ Authorization = "Basic $EncodedCredential"} + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2514,7 +3034,7 @@ Here's a simple example: requests.post("https://ntfy.example.com/mysecrets", data="Look ma, with auth", headers={ - "Authorization": "Basic cGhpbDpteXBhc3M=" + "Authorization": "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk" }) ``` @@ -2525,12 +3045,312 @@ Here's a simple example: 'method' => 'POST', // PUT also works 'header' => 'Content-Type: text/plain\r\n' . - 'Authorization: Basic cGhpbDpteXBhc3M=', + 'Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk', 'content' => 'Look ma, with auth' ] ])); ``` +To generate the `Authorization` header, use **standard base64** to encode the colon-separated `:` +and prepend the word `Basic`, i.e. `Authorization: Basic base64(:)`. Here's some pseudo-code that +hopefully explains it better: + +``` +username = "testuser" +password = "fakepassword" +authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk +``` + +The following command will generate the appropriate value for you on *nix systems: + +``` +echo "Basic $(echo -n 'testuser:fakepassword' | base64)" +``` + +### Access tokens +In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful +to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may +want to use a dedicated token to publish from your backup host, and one from your home automation system. + +You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in). +See [access tokens](config.md#access-tokens) for details. + +Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or +subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/), +with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`: + +=== "Command line (curl)" + ``` + curl \ + -H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell 7+" + ``` powershell + # With PowerShell 7 or greater, we can use the Authentication and Token parameters + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Authorization = "Bearer" + Token = "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "PowerShell 5 and earlier" + ``` powershell + # In PowerShell 5 and below, we can only send the Bearer token as a string in the Headers + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" } + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the +access token. When sending an empty username, the basic auth password is treated by the ntfy server as an +access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`: + +=== "Command line (curl)" + ``` + curl \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Note that PSCredentials *must* have a username, so we fall back to placing the authorization in the Headers as with PowerShell 5 + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ + Authorization = "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy" + } + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + + +### Query param +Here's an example using the `auth` query parameter: + +=== "Command line (curl)" + ``` + curl \ + -d "Look ma, with auth" \ + "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -u testuser:fakepassword \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw HTTP/1.1 + Host: ntfy.example.com + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', { + method: 'POST', // PUT works too + body: 'Look ma, with auth' + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw", + strings.NewReader("Look ma, with auth")) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw", + data="Look ma, with auth" + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/plain', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using +**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully +explains it better: + +``` +username = "testuser" +password = "fakepassword" +authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk +authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw (no trailing =) + +// If your language does not have a function to encode raw base64, simply use normal base64 +// and REMOVE TRAILING "=" characters. +``` + +The following command will generate the appropriate value for you on *nix systems: + +``` +echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' +``` + +For access tokens, you can use this instead: + +``` +echo -n "Bearer faketoken" | base64 | tr -d '=' +``` + +## Advanced features + ### Message caching !!! info If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a @@ -2586,10 +3406,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/mytopic" - $headers = @{ Cache="no" } - $body = "This message won't be stored server-side" - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Headers = @{ Cache="no" } + Body = "This message won't be stored server-side" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2666,10 +3489,13 @@ to `no`. This will instruct the server not to forward messages to Firebase. === "PowerShell" ``` powershell - $uri = "https://ntfy.sh/mytopic" - $headers = @{ Firebase="no" } - $body = "This message won't be forwarded to FCM" - Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Headers = @{ Firebase="no" } + Body = "This message won't be forwarded to FCM" + } + Invoke-RestMethod @Request ``` === "Python" @@ -2705,6 +3531,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64. +### Matrix Gateway +The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with +[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate +with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since +you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)). + +In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)), +and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the +ntfy Android app, and passed on to the Matrix client there. + +There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the +ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol. + +!!! info + This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy. + ## Public topics Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics that you can use to try out what [authentication and access control](#authentication) looks like. @@ -2713,31 +3555,40 @@ that you can use to try out what [authentication and access control](#authentica |------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------| | [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such | | [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage | -| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic | -| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic | -| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic | ## Limitations There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, but just in case, let's list them all: -| Limit | Description | -|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | -| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | -| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. | -| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. | -| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | -| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. | -| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | +| Limit | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | +| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | +| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | + +These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing +a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. ## List of all parameters -The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**, -and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form. +The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive** +when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the +table in their canonical form. -| Parameter | Aliases (case-insensitive) | Description | +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any + header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +| Parameter | Aliases | Description | |-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------| | `X-Message` | `Message`, `m` | Main body of the message as shown in the notification | | `X-Title` | `Title`, `t` | [Message title](#message-title) | @@ -2747,9 +3598,14 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) | | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) | | `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment | +| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body | +| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | +| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | +| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) | | `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics | +| `Content-Type` | - | If set to `text/markdown`, [Markdown formatting](#markdown-formatting) is enabled | diff --git a/docs/releases.md b/docs/releases.md index 508ec4f2..73e5eb20 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,15 +2,804 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). - - ## ntfy Android app v1.12.0 Released Apr 25, 2022 @@ -73,7 +874,7 @@ languages and fixed a ton of bugs. thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) * Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91)) -**Bugs:** +**Bug fixes:** * Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) @@ -114,7 +915,7 @@ Limited support is available in the web app. * Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting) * Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189)) -**Bugs:** +**Bug fixes:** * Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) * Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis)) @@ -154,7 +955,7 @@ Released Apr 7, 2022 * Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things) -**Bugs:** +**Bug fixes:** * IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting) * SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185)) @@ -188,7 +989,7 @@ Released Apr 6, 2022 * Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196)) -**Bugs:** +**Bug fixes:** * Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d)) @@ -204,7 +1005,7 @@ Released Apr 6, 2022 ## ntfy server v1.19.0 Released Mar 30, 2022 -**Bugs:** +**Bug fixes:** * Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos)) * Do not allow comma in topic name in publish via GET endpoint (no ticket) @@ -472,10 +1273,38 @@ Released Dec 28, 2021 **Features & bug fixes:** -* [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66 +* [Publish messages via e-mail](publish.md#e-mail-publishing) #66 * Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64 * Fixing the Santa bug #65 ## Older releases For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). + +## Not released yet + +### ntfy server v2.8.0 (UNRELEASED) + +**Bug fixes + maintenance:** + +* Fix ACL issue with topic patterns containing underscores ([#840](https://github.com/binwiederhier/ntfy/issues/840), thanks to [@Joe-0237](https://github.com/Joe-0237) for reporting) +* Re-add `tzdata` to Docker images for amd64 image ([#894](https://github.com/binwiederhier/ntfy/issues/894), [#307](https://github.com/binwiederhier/ntfy/pull/307)) +* Add special logic to ignore `Priority` header if it resembled a RFC 9218 value ([#851](https://github.com/binwiederhier/ntfy/pull/851)/[#895](https://github.com/binwiederhier/ntfy/pull/895), thanks to [@gusdleon](https://github.com/gusdleon), see also [#351](https://github.com/binwiederhier/ntfy/issues/351), [#353](https://github.com/binwiederhier/ntfy/issues/353), [#461](https://github.com/binwiederhier/ntfy/issues/461)) +* PWA: hide install prompt on macOS 14 Safari ([#899](https://github.com/binwiederhier/ntfy/pull/899), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves)) +* Fix web app crash in Edge for languages with underline in locale ([#922](https://github.com/binwiederhier/ntfy/pull/922)/[#912](https://github.com/binwiederhier/ntfy/issues/912)/[#852](https://github.com/binwiederhier/ntfy/issues/852), thanks to [@imkero](https://github.com/imkero)) + +### ntfy Android app v1.16.1 (UNRELEASED) + +**Features:** + +* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) + +**Bug fixes + maintenance:** + +* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8)) +* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing) +* Bumped all dependencies to the latest versions (no ticket) + +**Additional languages:** + +* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/)) diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 new file mode 100644 index 00000000..0cace65f Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.mp3 differ diff --git a/docs/static/audio/ntfy-phone-call.ogg b/docs/static/audio/ntfy-phone-call.ogg new file mode 100644 index 00000000..cbbf6b60 Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.ogg differ diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index 9d7e85d1..3c53aed6 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -1,15 +1,18 @@ -:root { +:root > * { --md-primary-fg-color: #338574; --md-primary-fg-color--light: #338574; --md-primary-fg-color--dark: #338574; + --md-footer-bg-color: #353744; + --md-text-font: "Roboto"; + --md-code-font: "Roboto Mono"; } .md-header__button.md-logo :is(img, svg) { width: unset !important; } -.md-sidebar { - width: 12.5rem !important; +.md-header__topic:first-child { + font-weight: 400; } .md-typeset h4 { @@ -30,12 +33,30 @@ figure img, figure video { border-radius: 7px; } -body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video { +header { + background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); +} + +body[data-md-color-scheme="default"] header { + filter: drop-shadow(0 5px 10px #ccc); +} + +body[data-md-color-scheme="slate"] header { + filter: drop-shadow(0 5px 10px #333); +} + +body[data-md-color-scheme="default"] figure img, +body[data-md-color-scheme="default"] figure video, +body[data-md-color-scheme="default"] .screenshots img, +body[data-md-color-scheme="default"] .screenshots video { filter: drop-shadow(3px 3px 3px #ccc); } -body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video { - filter: drop-shadow(3px 3px 3px #1a1313); +body[data-md-color-scheme="slate"] figure img, +body[data-md-color-scheme="slate"] figure video, +body[data-md-color-scheme="slate"] .screenshots img, +body[data-md-color-scheme="slate"] .screenshots video { + filter: drop-shadow(3px 3px 3px #353744); } figure video { @@ -50,7 +71,18 @@ figure video { } .remove-md-box td { - padding: 0 10px + padding: 0 10px; +} + +.emoji-table .c { + vertical-align: middle !important; +} + +.emoji-table .e { + font-size: 2.5em; + padding: 0 2px !important; + text-align: center !important; + vertical-align: middle !important; } /* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */ @@ -60,7 +92,8 @@ figure video { } .screenshots img { - height: 230px; + max-height: 230px; + max-width: 300px; margin: 3px; border-radius: 5px; filter: drop-shadow(2px 2px 2px #ddd); @@ -127,3 +160,57 @@ figure video { .lightbox .close-lightbox:hover::before { background-color: #fff; } + +/* roboto-300 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2'); +} + +/* roboto-regular - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2'); +} + +/* roboto-italic - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2'); +} + +/* roboto-500 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2'); +} + +/* roboto-700 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2'); +} + +/* roboto-mono - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2'); +} diff --git a/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 new file mode 100644 index 00000000..f8894bab Binary files /dev/null and b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-300.woff2 b/docs/static/fonts/roboto-v30-latin-300.woff2 new file mode 100644 index 00000000..60681387 Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-300.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-500.woff2 b/docs/static/fonts/roboto-v30-latin-500.woff2 new file mode 100644 index 00000000..29342a8d Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-500.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-700.woff2 b/docs/static/fonts/roboto-v30-latin-700.woff2 new file mode 100644 index 00000000..771fbecc Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-700.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-italic.woff2 b/docs/static/fonts/roboto-v30-latin-italic.woff2 new file mode 100644 index 00000000..e1b7a79f Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-italic.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-regular.woff2 b/docs/static/fonts/roboto-v30-latin-regular.woff2 new file mode 100644 index 00000000..020729ef Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-regular.woff2 differ diff --git a/docs/static/img/android-screenshot-icon.png b/docs/static/img/android-screenshot-icon.png new file mode 100644 index 00000000..68c57a16 Binary files /dev/null and b/docs/static/img/android-screenshot-icon.png differ diff --git a/docs/static/img/android-screenshot-logs.jpg b/docs/static/img/android-screenshot-logs.jpg new file mode 100644 index 00000000..e5f1d8e8 Binary files /dev/null and b/docs/static/img/android-screenshot-logs.jpg differ diff --git a/docs/static/img/cdio-setup.jpg b/docs/static/img/cdio-setup.jpg new file mode 100644 index 00000000..2f9e44cb Binary files /dev/null and b/docs/static/img/cdio-setup.jpg differ diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico new file mode 100644 index 00000000..857fa54c Binary files /dev/null and b/docs/static/img/favicon.ico differ diff --git a/docs/static/img/favicon.png b/docs/static/img/favicon.png deleted file mode 100644 index 92312fea..00000000 Binary files a/docs/static/img/favicon.png and /dev/null differ diff --git a/docs/static/img/grafana-dashboard.png b/docs/static/img/grafana-dashboard.png new file mode 100644 index 00000000..6cb12c80 Binary files /dev/null and b/docs/static/img/grafana-dashboard.png differ diff --git a/docs/static/img/pwa-badge.png b/docs/static/img/pwa-badge.png new file mode 100644 index 00000000..1a22de07 Binary files /dev/null and b/docs/static/img/pwa-badge.png differ diff --git a/docs/static/img/pwa-install-chrome-android-menu.jpg b/docs/static/img/pwa-install-chrome-android-menu.jpg new file mode 100644 index 00000000..1c258d64 Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-menu.jpg differ diff --git a/docs/static/img/pwa-install-chrome-android-popup.jpg b/docs/static/img/pwa-install-chrome-android-popup.jpg new file mode 100644 index 00000000..90c77c77 Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-popup.jpg differ diff --git a/docs/static/img/pwa-install-chrome-android.jpg b/docs/static/img/pwa-install-chrome-android.jpg new file mode 100644 index 00000000..7f89503f Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android.jpg differ diff --git a/docs/static/img/pwa-install-firefox-android-menu.jpg b/docs/static/img/pwa-install-firefox-android-menu.jpg new file mode 100644 index 00000000..c0aa59e7 Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-menu.jpg differ diff --git a/docs/static/img/pwa-install-firefox-android-popup.jpg b/docs/static/img/pwa-install-firefox-android-popup.jpg new file mode 100644 index 00000000..e97a858d Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-popup.jpg differ diff --git a/docs/static/img/pwa-install-macos-safari-add-to-dock.png b/docs/static/img/pwa-install-macos-safari-add-to-dock.png new file mode 100644 index 00000000..8a780605 Binary files /dev/null and b/docs/static/img/pwa-install-macos-safari-add-to-dock.png differ diff --git a/docs/static/img/pwa-install-safari-ios-add-icon.jpg b/docs/static/img/pwa-install-safari-ios-add-icon.jpg new file mode 100644 index 00000000..175fb8b4 Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-add-icon.jpg differ diff --git a/docs/static/img/pwa-install-safari-ios-button.jpg b/docs/static/img/pwa-install-safari-ios-button.jpg new file mode 100644 index 00000000..c9897c30 Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-button.jpg differ diff --git a/docs/static/img/pwa-install-safari-ios-menu.jpg b/docs/static/img/pwa-install-safari-ios-menu.jpg new file mode 100644 index 00000000..b6408afd Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-menu.jpg differ diff --git a/docs/static/img/pwa-install.png b/docs/static/img/pwa-install.png new file mode 100644 index 00000000..c44e7dbc Binary files /dev/null and b/docs/static/img/pwa-install.png differ diff --git a/docs/static/img/pwa.png b/docs/static/img/pwa.png new file mode 100644 index 00000000..c26f29f1 Binary files /dev/null and b/docs/static/img/pwa.png differ diff --git a/docs/static/img/rundeck.png b/docs/static/img/rundeck.png new file mode 100644 index 00000000..9c5a8a85 Binary files /dev/null and b/docs/static/img/rundeck.png differ diff --git a/docs/static/img/uptimekuma-ios-down.jpg b/docs/static/img/uptimekuma-ios-down.jpg new file mode 100644 index 00000000..330f86f9 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-down.jpg differ diff --git a/docs/static/img/uptimekuma-ios-test.jpg b/docs/static/img/uptimekuma-ios-test.jpg new file mode 100644 index 00000000..1b678a69 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-test.jpg differ diff --git a/docs/static/img/uptimekuma-ios-up.jpg b/docs/static/img/uptimekuma-ios-up.jpg new file mode 100644 index 00000000..919d9ba1 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-up.jpg differ diff --git a/docs/static/img/uptimekuma-settings.png b/docs/static/img/uptimekuma-settings.png new file mode 100644 index 00000000..5b21bdd6 Binary files /dev/null and b/docs/static/img/uptimekuma-settings.png differ diff --git a/docs/static/img/uptimekuma-setup.png b/docs/static/img/uptimekuma-setup.png new file mode 100644 index 00000000..cd57baa7 Binary files /dev/null and b/docs/static/img/uptimekuma-setup.png differ diff --git a/docs/static/img/uptimerobot-setup.jpg b/docs/static/img/uptimerobot-setup.jpg new file mode 100644 index 00000000..232d5366 Binary files /dev/null and b/docs/static/img/uptimerobot-setup.jpg differ diff --git a/docs/static/img/uptimerobot-test.jpg b/docs/static/img/uptimerobot-test.jpg new file mode 100644 index 00000000..d2fbfaec Binary files /dev/null and b/docs/static/img/uptimerobot-test.jpg differ diff --git a/docs/static/img/web-account.png b/docs/static/img/web-account.png new file mode 100644 index 00000000..48e916e5 Binary files /dev/null and b/docs/static/img/web-account.png differ diff --git a/docs/static/img/web-logs.png b/docs/static/img/web-logs.png new file mode 100644 index 00000000..2dfebdcc Binary files /dev/null and b/docs/static/img/web-logs.png differ diff --git a/docs/static/img/web-markdown.png b/docs/static/img/web-markdown.png new file mode 100644 index 00000000..612f2cf3 Binary files /dev/null and b/docs/static/img/web-markdown.png differ diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png new file mode 100644 index 00000000..335aeef1 Binary files /dev/null and b/docs/static/img/web-phone-verify.png differ diff --git a/docs/static/img/web-pin.png b/docs/static/img/web-pin.png deleted file mode 100644 index 3312a50f..00000000 Binary files a/docs/static/img/web-pin.png and /dev/null differ diff --git a/docs/static/img/web-reserve-topic-dialog.png b/docs/static/img/web-reserve-topic-dialog.png new file mode 100644 index 00000000..e4fb4b4a Binary files /dev/null and b/docs/static/img/web-reserve-topic-dialog.png differ diff --git a/docs/static/img/web-reserve-topic.png b/docs/static/img/web-reserve-topic.png new file mode 100644 index 00000000..a8ffd374 Binary files /dev/null and b/docs/static/img/web-reserve-topic.png differ diff --git a/docs/static/img/web-signup.png b/docs/static/img/web-signup.png new file mode 100644 index 00000000..62c7bd30 Binary files /dev/null and b/docs/static/img/web-signup.png differ diff --git a/docs/static/img/web-subscribe.png b/docs/static/img/web-subscribe.png index f60a8658..ccbd0493 100644 Binary files a/docs/static/img/web-subscribe.png and b/docs/static/img/web-subscribe.png differ diff --git a/docs/static/img/web-token-create.png b/docs/static/img/web-token-create.png new file mode 100644 index 00000000..29dcb387 Binary files /dev/null and b/docs/static/img/web-token-create.png differ diff --git a/docs/static/img/web-token-list.png b/docs/static/img/web-token-list.png new file mode 100644 index 00000000..b36b2a7f Binary files /dev/null and b/docs/static/img/web-token-list.png differ diff --git a/docs/static/js/extra.js b/docs/static/js/extra.js index 0aa380a7..6ddf07a9 100644 --- a/docs/static/js/extra.js +++ b/docs/static/js/extra.js @@ -1,8 +1,8 @@ // Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs -const savedTab = localStorage.getItem('savedTab') -const tabs = document.querySelectorAll(".tabbed-set > input") -for (const tab of tabs) { +const savedCodeTab = localStorage.getItem('savedTab') +const codeTabs = document.querySelectorAll(".tabbed-set > input") +for (const tab of codeTabs) { tab.addEventListener("click", () => { const current = document.querySelector(`label[for=${tab.id}]`) const pos = current.getBoundingClientRect().top @@ -25,7 +25,7 @@ for (const tab of tabs) { // Select saved tab const current = document.querySelector(`label[for=${tab.id}]`) const labelContent = current.innerHTML - if (savedTab === labelContent) { + if (savedCodeTab === labelContent) { tab.checked = true } } diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index 67d3458f..58da9752 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -87,7 +87,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript, ### Subscribe as SSE stream Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly -easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html). +easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource). === "Command line (curl)" ``` @@ -267,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1" ``` ### Filter messages -You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and +You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and `tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags "zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND. @@ -280,12 +280,13 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error" Available filters (all case-insensitive): -| Filter variable | Alias | Example | Description | -|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------| -| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string | -| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string | -| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | -| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | +| Filter variable | Alias | Example | Description | +|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------| +| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID | +| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string | +| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string | +| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | ### Subscribe to multiple topics It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics @@ -301,13 +302,12 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json ### Authentication Depending on whether the server is configured to support [access control](../config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. -To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) -with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing -your password. +To publish/subscribe to protected topics, you can: -``` -curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json" -``` +* Use [basic auth](../publish.md#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk` +* or use the [`auth` query parameter](../publish.md#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw` + +Please refer to the [publishing documentation](../publish.md#authentication) for additional details. ## JSON message format Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON @@ -315,18 +315,20 @@ format of the message. It's very straight forward: **Message**: -| Field | Required | Type | Example | Description | -|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | -| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | -| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | -| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | -| `message` | - | *string* | `Some message` | Message body; always present in `message` events | -| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | -| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | -| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | +| Field | Required | Type | Example | Description | +|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | +| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | +| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | +| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | +| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | +| `message` | - | *string* | `Some message` | Message body; always present in `message` events | +| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | +| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | +| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | +| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): @@ -345,6 +347,7 @@ Here's an example for each message type: { "id": "sPs71M8A2T", "time": 1643935928, + "expires": 1643936928, "event": "message", "topic": "mytopic", "priority": 5, @@ -371,6 +374,7 @@ Here's an example for each message type: { "id": "wze9zgqK41", "time": 1638542110, + "expires": 1638543112, "event": "message", "topic": "phil_alerts", "message": "Remote access to phils-laptop detected. Act right away." @@ -416,6 +420,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `poll` | `X-Poll`, `po` | Return cached messages and close connection | | `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID | | `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | +| `id` | `X-ID` | Filter: Only return messages that match this exact message ID | | `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string | | `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string | | `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) | diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index 52e005c0..7f589d3c 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -10,7 +10,7 @@ to topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that c ## Install + configure To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client -by creating `~/.config/ntfy/client.yml` (for the non-root user), or `/etc/ntfy/client.yml` (for the root user). You +by creating `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user). You can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub. If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**, @@ -56,6 +56,71 @@ quick ones: ntfy pub mywebhook ``` +### Attaching a local file +You can easily upload and attach a local file to a notification: + +``` +$ ntfy pub --file README.md mytopic | jq . +{ + "id": "meIlClVLABJQ", + "time": 1655825460, + "event": "message", + "topic": "mytopic", + "message": "You received a file: README.md", + "attachment": { + "name": "README.md", + "type": "text/plain; charset=utf-8", + "size": 2892, + "expires": 1655836260, + "url": "https://ntfy.sh/file/meIlClVLABJQ.txt" + } +} +``` + +### Wait for PID/command +If you have a long-running command and want to **publish a notification when the command completes**, +you may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the +command is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`). + +Run a command and wait for it to complete (here: `rsync ...`): + +``` +$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq . +{ + "id": "Re0rWXZQM8WB", + "time": 1655825624, + "event": "message", + "topic": "mytopic", + "message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/" +} +``` + +Or, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this: + +=== "Using a PID directly" + ``` + $ ntfy pub --wait-pid 8458 mytopic | jq . + { + "id": "orM6hJKNYkWb", + "time": 1655825827, + "event": "message", + "topic": "mytopic", + "message": "Process with PID 8458 exited after 2.003s" + } + ``` + +=== "Using a `pidof`" + ``` + $ ntfy pub --wait-pid $(pidof rsync) mytopic | jq . + { + "id": "orM6hJKNYkWb", + "time": 1655825827, + "event": "message", + "topic": "mytopic", + "message": "Process with PID 8458 exited after 2.003s" + } + ``` + ## Subscribe to topics You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command will either print or execute a command for every arriving message. There are a few different ways @@ -123,7 +188,7 @@ which will read the `subscribe` config from the config file. Please also check o Here's an example config file that subscribes to three different topics, executing a different command for each of them: -=== "~/.config/ntfy/client.yml" +=== "~/.config/ntfy/client.yml (Linux)" ```yaml subscribe: - topic: echo-this @@ -145,12 +210,42 @@ Here's an example config file that subscribes to three different topics, executi fi ``` + +=== "~/Library/Application Support/ntfy/client.yml (macOS)" + ```yaml + subscribe: + - topic: echo-this + command: 'echo "Message received: $message"' + - topic: alerts + command: osascript -e "display notification \"$message\"" + if: + priority: high,urgent + - topic: calc + command: open -a Calculator + ``` + +=== "%AppData%\ntfy\client.yml (Windows)" + ```yaml + subscribe: + - topic: echo-this + command: 'echo Message received: %message%' + - topic: alerts + command: | + notifu /m "%NTFY_MESSAGE%" + exit 0 + if: + priority: high,urgent + - topic: calc + command: calc + ``` + In this example, when `ntfy subscribe --from-config` is executed: * Messages to `echo-this` simply echos to standard out -* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) -* Messages to `calc` open the gnome calculator 😀 (*because, why not*) -* Messages to `print-temp` execute an inline script and print the CPU temperature +* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux), + [notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS) +* Messages to `calc` open the calculator 😀 (*because, why not*) +* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only) I hope this shows how powerful this command is. Here's a short video that demonstrates the above example: @@ -159,6 +254,14 @@ I hope this shows how powerful this command is. Here's a short video that demons
Execute all the things
+If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both). +You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults +will be used, otherwise, the subscription settings will override the defaults. + +!!! warning + Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not + require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. + ### Using the systemd service You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service)) to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started) diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index 598eeb63..e88ff0fb 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -1,14 +1,19 @@ # Subscribe from your phone -You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive -notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android). -Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4). +You can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347) +to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available +on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to +contribute, or [build your own](../develop.md). + You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that -the F-Droid flavor does not use Firebase. +the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). + +Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app. +The PWA is a website that you can add to your home screen, and it will behave just like a native app. ## Overview A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty @@ -31,6 +36,8 @@ If those screenshots are still not enough, here's a video: ## Message priority +_Supported on:_ :material-android: :material-apple: + When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate. @@ -59,6 +66,8 @@ setting, and other settings such as popover or notification dot: ## Instant delivery +_Supported on:_ :material-android: + Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e. when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which you'll see as a permanent notification that looks like this: @@ -89,6 +98,8 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor. ## Share to topic +_Supported on:_ :material-android: + You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics you shared content to and lists them at the bottom. @@ -101,6 +112,8 @@ The feature is pretty self-explanatory, and one picture says more than a thousan ## ntfy:// links +_Supported on:_ :material-android: + The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps) such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), or to simply directly link to a topic from a mobile website. @@ -119,6 +132,8 @@ or to simply directly link to a topic from a mobile website. ## Integrations ### UnifiedPush +_Supported on:_ :material-android: + [UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. @@ -134,6 +149,8 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/): ### Automation apps +_Supported on:_ :material-android: + The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can **react to incoming messages**, as well as **send messages**. @@ -166,21 +183,27 @@ notification popups: Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`: -| Extra name | Type | Example | Description | -|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------| -| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) | -| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from | -| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** | -| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app | -| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` | -| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp | -| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set | -| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** | -| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data | -| `encoding`️ | *String* | - | Message encoding (empty or "base64") | -| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | -| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag | -| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| Extra name | Type | Example | Description | +|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------| +| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) | +| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from | +| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** | +| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app | +| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` | +| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp | +| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set | +| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** | +| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data | +| `encoding`️ | *String* | - | Message encoding (empty or "base64") | +| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | +| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag | +| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | *String* | `https://google.com` | [Click action](../publish.md#click-action) URL, or empty if not set | +| `attachment_name` | *String* | `attachment.jpg` | Filename of the attachment; may be empty if not set | +| `attachment_type` | *String* | `image/jpeg` | Mime type of the attachment; may be empty if not set | +| `attachment_size` | *Long* | `9923111` | Size in bytes of the attachment; may be zero if not set | +| `attachment_expires` | *Long* | `1655514244` | Expiry date as Unix timestamp of the attachment URL; may be zero if not set | +| `attachment_url` | *String* | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set | #### Send messages using intents To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) @@ -210,9 +233,3 @@ The following intent extras are supported when for the intent with the `io.hecke | `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** | | `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | | `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | - -## iPhone/iOS -I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app -for ntfy, but it's in the works. You can track the status on GitHub. - - diff --git a/docs/subscribe/pwa.md b/docs/subscribe/pwa.md new file mode 100644 index 00000000..5dcaa257 --- /dev/null +++ b/docs/subscribe/pwa.md @@ -0,0 +1,69 @@ +# Using the progressive web app (PWA) +While ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA) +and thus can be **installed on both desktop and mobile devices**. + +This gives it its own launcher (e.g. shortcut on Windows, app on macOS, launcher shortcut on Linux, home screen icon on iOS, and +launcher icon on Android), a standalone window, push notifications, and an app badge with the unread notification count. + +Web app installation is **supported on** (see [compatibility table](https://caniuse.com/web-app-manifest) for details): + +- **Chrome:** Android, Windows, Linux, macOS +- **Safari:** iOS 16.4+, macOS 14+ +- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/) +- **Edge:** Windows + +Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work. + +## Installation + +### Chrome on Desktop +To install and register the web app via Chrome, click the "install app" icon. After installation, you can find the app in your +app drawer: + +
+ + + +
+ +### Safari on macOS +To install and register the web app via Safari, click on the Share menu and click Add to Dock. You need to be on macOS Sonoma (14) or higher. + +
+ +
+ +### Chrome/Firefox on Android +For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app" +in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer, +and on your home screen. + +
+ + + +
+ +For Firefox, select "Install" in the menu, and then click "Add" to add an icon to your home screen: + +
+ + +
+ +### Safari on iOS +On iOS Safari, tap on the Share menu, then tap "Add to Home Screen": + +
+ + + +
+ +## Background notifications +Background notifications via web push are enabled by default and cannot be turned off when the app is installed, as notifications would +not be delivered reliably otherwise. You can mute topics you don't want to receive notifications for. + +On desktop, you generally need either your browser or the web app open to receive notifications, though the ntfy tab doesn't need to be +open. On mobile, you don't need to have the web app open to receive notifications. Look at the [web docs](./web.md#background-notifications) +for a detailed breakdown. diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md index 74763b83..859f7d0a 100644 --- a/docs/subscribe/web.md +++ b/docs/subscribe/web.md @@ -1,20 +1,75 @@ -# Subscribe from the Web UI -You can use the Web UI to subscribe to topics as well. If you do, and you keep the website open, **notifications will -pop up as desktop notifications**. Simply type in the topic name and click the *Subscribe* button. The browser will -keep a connection open and listen for incoming notifications. +# Subscribe from the web app +The web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app). +To subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic +will appear in the web app, and pop up as a notification.** +
+ +
+ +## Publish messages To learn how to send messages, check out the [publishing page](../publish.md).
-
-To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend, -is to pin the tab so that it's always open, but sort of out of the way: +## Topic reservations +If topic reservations are enabled, you can claim ownership over topics and define access to it: -
- ![pinned](../static/img/web-pin.png){ width=500 } -
Pin web app to move it out of the way
-
+
+ + +
+ +## Notification features and browser support + +- Emoji tags are supported in all browsers + +- [Click](../publish.md#click-action) actions are supported in all browsers + +- Only Chrome, Edge, and Opera support displaying view and http [actions](../publish.md#action-buttons) in notifications. + + Their presentation is platform specific. + + Note that HTTP actions are performed using fetch and thus are limited to the [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) + rules, which means that any URL you include needs to respond to a [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) + with headers allowing the origin of the ntfy web app (`Access-Control-Allow-Origin: https://ntfy.sh`) or `*`. + +- Only Chrome, Edge, and Opera support displaying [images](../publish.md#attachments) in notifications. + +Look at the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility) +for more info. + +## Background notifications +While subscribing, you have the option to enable background notifications on supported browsers (see "Settings" tab). + +Note: If you add the web app to your homescreen (as a progressive web app, more info in the [installed web app](pwa.md) +docs), you cannot turn these off, as notifications would not be delivered reliably otherwise. You can mute topics you don't want to receive +notifications for. + +**If background notifications are off:** This requires an active ntfy tab to be open to receive notifications. +These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser +is allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications, +**pinning the ntfy tab on your browser** is a good solution to leave it running. + +**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active +ntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the +same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself. + +If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them +by opening the app again, and will get a warning notification before they are paused. + +| Browser | Platform | Browser Running | Browser Not Running | Restrictions | +|---------|----------|-----------------|---------------------|---------------------------------------------------------| +| Chrome | Desktop | ✅ | ❌ | | +| Firefox | Desktop | ✅ | ❌ | | +| Edge | Desktop | ✅ | ❌ | | +| Opera | Desktop | ✅ | ❌ | | +| Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura | +| Chrome | Android | ✅ | ✅ | | +| Firefox | Android | ✅ | ✅ | | +| Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen | + +(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..d37561c5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,131 @@ +# Troubleshooting +This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list. +If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) +and ask there. We're happy to help. + +## ntfy server +If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing +in the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately +boils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file: + +=== "server.yml (debug)" + ``` yaml + log-level: debug + ``` + +=== "server.yml (trace)" + ``` yaml + log-level: trace + ``` + +If you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace` +to the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at +the logs using `journalctl -u ntfy -f`. The logs will look something like this: + +=== "Example logs (debug)" + ``` + $ ntfy serve --debug + 2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup) + 2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) + 2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache) + ... + ``` + +=== "Example logs (trace)" + ``` + $ ntfy serve --trace + 2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup) + 2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) + 2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1 + User-Agent: curl/7.81.0 + Accept: */* + Content-Length: 2 + Content-Type: application/x-www-form-urlencoded + + hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={ + "id": "Khaup1RVclU3", + "time": 1679337659, + "expires": 1679380859, + "event": "message", + "topic": "mytopic", + "message": "hi" + }, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache) + ... + ``` + +## Android app +On Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log +entries, which you can then copy or upload. + +
+ ![Recording logs on Android](static/img/android-screenshot-logs.jpg){ width=400 } +
Recording logs on Android
+
+ +When you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all +topics and hostnames with fruits. Here's an example: + +``` +This is a log of the ntfy Android app. The log shows up to 1,000 entries. +Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑. + +Device info: +-- +ntfy: 1.16.0 (play) +OS: 4.19.157-perf+ +Android: 13 (SDK 33) +... + +Logs +-- + +1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false +1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service +1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b) +1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262 +1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})] +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})] +... +``` + +To get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb). +After you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can +get detailed logs like so: + +``` +# Connect to phone (enable Wireless debugging first) +adb connect 192.168.1.137:39539 + +# Print all logs; you may have to pass the -s option +adb logcat +adb -s 192.168.1.137:39539 logcat + +# Only list ntfy logs +adb logcat --pid=$(adb shell pidof -s io.heckel.ntfy) +adb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy) +``` + +## Web app +The web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your +keyboard. + +
+ ![Web app logs](static/img/web-logs.png) +
Web app logs in the developer console
+
+ +## iOS app +Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode. diff --git a/examples/grafana-dashboard/ntfy-grafana.json b/examples/grafana-dashboard/ntfy-grafana.json new file mode 100644 index 00000000..11273da3 --- /dev/null +++ b/examples/grafana-dashboard/ntfy-grafana.json @@ -0,0 +1,2400 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "9.4.3" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 38, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "light-green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 36, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "ntfy_messages_published_success{job=\"$job\"}", + "legendFormat": "Messages cached", + "range": true, + "refId": "A" + } + ], + "title": "Published", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 33, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_cached_total{job=\"$job\"}", + "legendFormat": "Messages cached", + "range": true, + "refId": "A" + } + ], + "title": "Cached", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#69bfb5", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 31, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_visitors_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + } + ], + "title": "Visitors", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 12, + "y": 1 + }, + "id": 32, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_users_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + } + ], + "title": "Users", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 16, + "y": 1 + }, + "id": 34, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_topics_total{job=\"$job\"}", + "legendFormat": "Topics", + "range": true, + "refId": "A" + } + ], + "title": "Topics", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 1 + }, + "id": 35, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_subscribers_total", + "legendFormat": "Subscribers", + "range": true, + "refId": "A" + } + ], + "title": "Subscribers", + "type": "stat" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 10, + "title": "Metrics", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of successfully published messages, and messages that could not be published (due to rate limiting, bad formatting, etc.)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "custom.axisColorMode", + "value": "text" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 42, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_messages_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_messages_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failed", + "range": true, + "refId": "B" + } + ], + "title": "Messages published (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of messages published since last ntfy server restart", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_published_success{job=\"$job\"}", + "legendFormat": "Successful", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_published_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failed", + "range": true, + "refId": "B" + } + ], + "title": "Messages published", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of messages currently stored in message cache", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_cached_total{job=\"$job\"}", + "legendFormat": "Messages in database", + "range": true, + "refId": "A" + } + ], + "title": "Messages cached", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_visitors_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_topics_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Topics", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_subscribers_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Subscribers", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_users_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Users", + "range": true, + "refId": "D" + } + ], + "title": "Visitors, subscribers, topics", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 12 + }, + "id": 43, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(job) (rate(ntfy_http_requests_total{job=\"$job\"}[$rate]))", + "legendFormat": "Requests per second", + "range": true, + "refId": "A" + } + ], + "title": "HTTP requests (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 6, + "y": 12 + }, + "id": 41, + "options": { + "legend": { + "calcs": [ + "mean" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(http_code) (rate(ntfy_http_requests_total{job=\"$job\", http_code!=\"200\", http_code!=\"429\", http_code!=\"507\"}[$rate]))", + "legendFormat": "{{http_code}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP errors (per second, excl. 429/507)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 15, + "y": 12 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "mean" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(ntfy_code) (rate(ntfy_http_requests_total{http_code!=\"200\", job=\"$job\"}[$rate]))", + "legendFormat": "{{http_method}} {{http_code}} {{ntfy_code}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP errors (per second, ntfy code)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 19 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_attachments_total_size{job=\"$job\"}", + "legendFormat": "Total size in MB", + "range": true, + "refId": "A" + } + ], + "title": "Attachments: Total cache size", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": -1, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 19 + }, + "id": 27, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_firebase_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_firebase_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Firebase messages sent", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Rejected (HTTP 507)" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 19 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_unifiedpush_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_http_requests_total{job=\"$job\",http_code=\"507\"}[$rate])", + "hide": false, + "legendFormat": "Rejected (HTTP 507)", + "range": true, + "refId": "B" + } + ], + "title": "UnifiedPush messages", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_matrix_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_matrix_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Matrix messages published", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 26 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_sent_success{job=\"$job\"}", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_sent_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Emails sent", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 26 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_received_success{job=\"$job\"}", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_received_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Emails received", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 26 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_message_publish_duration_ms{job=\"$job\"}", + "legendFormat": "Duration", + "range": true, + "refId": "A" + } + ], + "title": "Message publish duration", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 8, + "panels": [], + "title": "Internals", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 34 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "go_goroutines{job=\"$job\"}", + "legendFormat": "Go routines", + "range": true, + "refId": "A" + } + ], + "title": "Go routines", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "log": 10, + "type": "symlog" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 34 + }, + "id": 44, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_open_fds{job=\"$job\"}", + "legendFormat": "Open", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_max_fds{job=\"$job\"}", + "hide": false, + "legendFormat": "Max", + "range": true, + "refId": "B" + } + ], + "title": "File descriptors", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 34 + }, + "id": 45, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_resident_memory_bytes{job=\"$job\"}", + "legendFormat": "Resident memory used by ntfy (RSS)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_virtual_memory_bytes{job=\"$job\"}", + "hide": false, + "legendFormat": "Virtual memory used by ntfy (VSS)", + "range": true, + "refId": "B" + } + ], + "title": "Resident/virtual memory", + "type": "timeseries" + } + ], + "refresh": "10s", + "revision": 1, + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "definition": "label_values(ntfy_visitors_total, job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(ntfy_visitors_total, job)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "selected": false, + "text": "30m", + "value": "30m" + }, + "description": "Average per-second rates over values from this time span", + "hide": 0, + "label": "Rate", + "name": "rate", + "options": [ + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": true, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + } + ], + "query": "1m,5m,10m,30m,1h", + "queryValue": "", + "refresh": 2, + "skipUrlSync": false, + "type": "interval" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "ntfy App", + "uid": "TO6HgexVz", + "version": 24, + "weekStart": "" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 76669086..a7395d5b 100644 --- a/go.mod +++ b/go.mod @@ -1,53 +1,79 @@ -module heckel.io/ntfy +module git.zio.sh/astra/ntfy/v2 -go 1.17 +go 1.18 require ( - cloud.google.com/go/firestore v1.6.1 // indirect - cloud.google.com/go/storage v1.22.0 // indirect - firebase.google.com/go v3.13.0+incompatible - github.com/BurntSushi/toml v1.1.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect - github.com/emersion/go-smtp v0.15.0 - github.com/gabriel-vasile/mimetype v1.4.0 - github.com/gorilla/websocket v1.5.0 - github.com/mattn/go-sqlite3 v1.14.12 - github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 - github.com/stretchr/testify v1.7.0 - github.com/urfave/cli/v2 v2.4.7 - golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 - golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 - golang.org/x/time v0.0.0-20220411224347-583f2d630306 - google.golang.org/api v0.75.0 + cloud.google.com/go/firestore v1.14.0 // indirect + cloud.google.com/go/storage v1.34.1 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/emersion/go-smtp v0.18.0 + github.com/gabriel-vasile/mimetype v1.4.3 + github.com/gorilla/websocket v1.5.1 + github.com/mattn/go-sqlite3 v1.14.18 + github.com/olebedev/when v1.0.0 + github.com/stretchr/testify v1.8.1 + github.com/urfave/cli/v2 v2.25.7 + golang.org/x/crypto v0.14.0 + golang.org/x/oauth2 v0.13.0 // indirect + golang.org/x/sync v0.5.0 + golang.org/x/term v0.13.0 + golang.org/x/time v0.4.0 + google.golang.org/api v0.149.0 gopkg.in/yaml.v2 v2.4.0 ) +replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pin version due to breaking changes, see #839 + require github.com/pkg/errors v0.9.1 // indirect require ( - cloud.google.com/go v0.101.0 // indirect - cloud.google.com/go/compute v1.6.1 // indirect - cloud.google.com/go/iam v0.3.0 // indirect - github.com/AlekSi/pointer v1.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.7 // indirect - github.com/googleapis/gax-go/v2 v2.3.0 // indirect - github.com/googleapis/go-type-adapters v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - go.opencensus.io v0.23.0 // indirect - golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect - golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect - golang.org/x/text v0.3.7 // indirect - golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect - google.golang.org/grpc v1.46.0 // indirect - google.golang.org/protobuf v1.28.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect + firebase.google.com/go/v4 v4.12.1 + github.com/SherClockHolmes/webpush-go v1.3.0 + github.com/prometheus/client_golang v1.17.0 + github.com/stripe/stripe-go/v74 v74.30.0 +) + +require ( + cloud.google.com/go v0.110.10 // indirect + cloud.google.com/go/compute v1.23.3 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/iam v1.1.5 // indirect + cloud.google.com/go/longrunning v0.5.4 // indirect + github.com/AlekSi/pointer v1.2.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.opencensus.io v0.24.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/appengine/v2 v2.0.5 // indirect + google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f25a86c9..d93f52d2 100644 --- a/go.sum +++ b/go.sum @@ -1,657 +1,241 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= -cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84= -cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= -cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc= -cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= -cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= -cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= -cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8= -cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= -firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= -github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= +cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= +cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/firestore v1.14.0 h1:8aLcKnMPoldYU3YHgu4t2exrKhLQkqaXAGqT0ljrFVw= +cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ= +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg= +cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= +cloud.google.com/go/storage v1.34.1 h1:H2Af2dU5J0PF7A5B+ECFIce+RqxVnrVilO+cu0TS3MI= +cloud.google.com/go/storage v1.34.1/go.mod h1:VN1ElqqvR9adg1k9xlkUJ55cMOP1/QjnNNuT5xQL6dY= +firebase.google.com/go/v4 v4.12.1 h1:tDNvobifGsx/1HSFLnM0fmNfx/CDZSgsTO2KhZtgpcs= +firebase.google.com/go/v4 v4.12.1/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k= +github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs= -github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= -github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= +github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= -github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= -github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= -github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI= -github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA= -github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= -github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk= -github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w= +github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= 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/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/urfave/cli/v2 v2.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo= -github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= +github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -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.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= -golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8= -golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc= -golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.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/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= -golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= +golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 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= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= -google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= -google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg= -google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY= +google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/appengine/v2 v2.0.5 h1:4C+F3Cd3L2nWEfSmFEZDPjQvDwL8T0YCeZBysZifP3k= +google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE= -google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405 h1:I6WNifs6pF9tNdSob2W24JtyxIYjzFB9qDlpUC76q+U= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405/go.mod h1:3WDQMjmJk36UQhjQ89emUzb1mdaHcPeeAh4SCBKznB4= +google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 h1:HJMDndgxest5n2y77fnErkM62iUsptE/H8p0dC2Huo4= +google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405/go.mod h1:oT32Z4o8Zv2xPQTg0pbVaPr0MPOH6f14RgXt7zfIpwg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -660,30 +244,18 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/log/event.go b/log/event.go new file mode 100644 index 00000000..c4521674 --- /dev/null +++ b/log/event.go @@ -0,0 +1,245 @@ +package log + +import ( + "encoding/json" + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "log" + "os" + "sort" + "strings" + "time" +) + +const ( + fieldTag = "tag" + fieldError = "error" + fieldTimeTaken = "time_taken_ms" + fieldExitCode = "exit_code" + tagStdLog = "stdlog" +) + +// Event represents a single log event +type Event struct { + Timestamp string `json:"time"` + Level Level `json:"level"` + Message string `json:"message"` + time time.Time + contexters []Contexter + fields Context +} + +// newEvent creates a new log event +// +// We delay allocations and processing for efficiency, because most log events +// are never actually rendered, so we don't format the time, or allocate a fields map. +func newEvent() *Event { + return &Event{ + time: time.Now(), + } +} + +// Fatal logs the event as FATAL, and exits the program with exit code 1 +func (e *Event) Fatal(message string, v ...any) { + e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...) + fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr + os.Exit(1) +} + +// Error logs the event with log level error +func (e *Event) Error(message string, v ...any) *Event { + return e.Log(ErrorLevel, message, v...) +} + +// Warn logs the event with log level warn +func (e *Event) Warn(message string, v ...any) *Event { + return e.Log(WarnLevel, message, v...) +} + +// Info logs the event with log level info +func (e *Event) Info(message string, v ...any) *Event { + return e.Log(InfoLevel, message, v...) +} + +// Debug logs the event with log level debug +func (e *Event) Debug(message string, v ...any) *Event { + return e.Log(DebugLevel, message, v...) +} + +// Trace logs the event with log level trace +func (e *Event) Trace(message string, v ...any) *Event { + return e.Log(TraceLevel, message, v...) +} + +// Tag adds a "tag" field to the log event +func (e *Event) Tag(tag string) *Event { + return e.Field(fieldTag, tag) +} + +// Time sets the time field +func (e *Event) Time(t time.Time) *Event { + e.time = t + return e +} + +// Timing runs f and records the time if took to execute it in "time_taken_ms" +func (e *Event) Timing(f func()) *Event { + start := time.Now() + f() + return e.Field(fieldTimeTaken, time.Since(start).Milliseconds()) +} + +// Err adds an "error" field to the log event +func (e *Event) Err(err error) *Event { + if err == nil { + return e + } else if c, ok := err.(Contexter); ok { + return e.With(c) + } + return e.Field(fieldError, err.Error()) +} + +// Field adds a custom field and value to the log event +func (e *Event) Field(key string, value any) *Event { + if e.fields == nil { + e.fields = make(Context) + } + e.fields[key] = value + return e +} + +// FieldIf adds a custom field and value to the log event if the given level is loggable +func (e *Event) FieldIf(key string, value any, level Level) *Event { + if e.Loggable(level) { + return e.Field(key, value) + } + return e +} + +// Fields adds a map of fields to the log event +func (e *Event) Fields(fields Context) *Event { + if e.fields == nil { + e.fields = make(Context) + } + for k, v := range fields { + e.fields[k] = v + } + return e +} + +// With adds the fields of the given Contexter structs to the log event by calling their Context method +func (e *Event) With(contexters ...Contexter) *Event { + if e.contexters == nil { + e.contexters = contexters + } else { + e.contexters = append(e.contexters, contexters...) + } + return e +} + +// Render returns the rendered log event as a string, or an empty string. The event is only rendered, +// if either the global log level is >= l, or if the log level in one of the overrides matches +// the level. +// +// If no overrides are defined (default), the Contexter array is not applied unless the event +// is actually logged. If overrides are defined, then Contexters have to be applied in any case +// to determine if they match. This is super complicated, but required for efficiency. +func (e *Event) Render(l Level, message string, v ...any) string { + appliedContexters := e.maybeApplyContexters() + if !e.Loggable(l) { + return "" + } + e.Message = fmt.Sprintf(message, v...) + e.Level = l + e.Timestamp = util.FormatTime(e.time) + if !appliedContexters { + e.applyContexters() + } + if CurrentFormat() == JSONFormat { + return e.JSON() + } + return e.String() +} + +// Log logs the event to the defined output, or does nothing if Render returns an empty string +func (e *Event) Log(l Level, message string, v ...any) *Event { + if m := e.Render(l, message, v...); m != "" { + log.Println(m) + } + return e +} + +// Loggable returns true if the given log level is lower or equal to the current log level +func (e *Event) Loggable(l Level) bool { + return e.globalLevelWithOverride() <= l +} + +// IsTrace returns true if the current log level is TraceLevel +func (e *Event) IsTrace() bool { + return e.Loggable(TraceLevel) +} + +// IsDebug returns true if the current log level is DebugLevel or below +func (e *Event) IsDebug() bool { + return e.Loggable(DebugLevel) +} + +// JSON returns the event as a JSON representation +func (e *Event) JSON() string { + b, _ := json.Marshal(e) + s := string(b) + if len(e.fields) > 0 { + b, _ := json.Marshal(e.fields) + s = fmt.Sprintf("{%s,%s}", s[1:len(s)-1], string(b[1:len(b)-1])) + } + return s +} + +// String returns the event as a string +func (e *Event) String() string { + if len(e.fields) == 0 { + return fmt.Sprintf("%s %s", e.Level.String(), e.Message) + } + fields := make([]string, 0) + for k, v := range e.fields { + fields = append(fields, fmt.Sprintf("%s=%v", k, v)) + } + sort.Strings(fields) + return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) +} + +func (e *Event) globalLevelWithOverride() Level { + mu.RLock() + l, ov := level, overrides + mu.RUnlock() + if e.fields == nil { + return l + } + for field, fieldOverrides := range ov { + value, exists := e.fields[field] + if exists { + for _, o := range fieldOverrides { + if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) { + return o.level + } + } + } + } + return l +} + +func (e *Event) maybeApplyContexters() bool { + mu.RLock() + hasOverrides := len(overrides) > 0 + mu.RUnlock() + if hasOverrides { + e.applyContexters() + } + return hasOverrides // = applied +} + +func (e *Event) applyContexters() { + for _, c := range e.contexters { + e.Fields(c.Context()) + } +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 00000000..20ad6151 --- /dev/null +++ b/log/log.go @@ -0,0 +1,206 @@ +package log + +import ( + "io" + "log" + "os" + "strings" + "sync" + "time" +) + +// Defaults for package level variables +var ( + DefaultLevel = InfoLevel + DefaultFormat = TextFormat + DefaultOutput = &peekLogWriter{os.Stderr} +) + +var ( + level = DefaultLevel + format = DefaultFormat + overrides = make(map[string][]*levelOverride) + output io.Writer = DefaultOutput + filename = "" + mu = &sync.RWMutex{} +) + +// init sets the default log output (including log.SetOutput) +// +// This has to be explicitly called, because DefaultOutput is a peekLogWriter, +// which wraps os.Stderr. +func init() { + SetOutput(DefaultOutput) +} + +// Fatal prints the given message, and exits the program +func Fatal(message string, v ...any) { + newEvent().Fatal(message, v...) +} + +// Error prints the given message, if the current log level is ERROR or lower +func Error(message string, v ...any) { + newEvent().Error(message, v...) +} + +// Warn prints the given message, if the current log level is WARN or lower +func Warn(message string, v ...any) { + newEvent().Warn(message, v...) +} + +// Info prints the given message, if the current log level is INFO or lower +func Info(message string, v ...any) { + newEvent().Info(message, v...) +} + +// Debug prints the given message, if the current log level is DEBUG or lower +func Debug(message string, v ...any) { + newEvent().Debug(message, v...) +} + +// Trace prints the given message, if the current log level is TRACE +func Trace(message string, v ...any) { + newEvent().Trace(message, v...) +} + +// With creates a new log event and adds the fields of the given Contexter structs +func With(contexts ...Contexter) *Event { + return newEvent().With(contexts...) +} + +// Field creates a new log event and adds a custom field and value to it +func Field(key string, value any) *Event { + return newEvent().Field(key, value) +} + +// Fields creates a new log event and adds a map of fields to it +func Fields(fields Context) *Event { + return newEvent().Fields(fields) +} + +// Tag creates a new log event and adds a "tag" field to it +func Tag(tag string) *Event { + return newEvent().Tag(tag) +} + +// Time creates a new log event and sets the time field +func Time(time time.Time) *Event { + return newEvent().Time(time) +} + +// Timing runs f and records the time if took to execute it in "time_taken_ms" +func Timing(f func()) *Event { + return newEvent().Timing(f) +} + +// CurrentLevel returns the current log level +func CurrentLevel() Level { + mu.RLock() + defer mu.RUnlock() + return level +} + +// SetLevel sets a new log level +func SetLevel(newLevel Level) { + mu.Lock() + defer mu.Unlock() + level = newLevel +} + +// SetLevelOverride adds a log override for the given field +func SetLevelOverride(field string, value string, level Level) { + mu.Lock() + defer mu.Unlock() + if _, ok := overrides[field]; !ok { + overrides[field] = make([]*levelOverride, 0) + } + overrides[field] = append(overrides[field], &levelOverride{value: value, level: level}) +} + +// ResetLevelOverrides removes all log level overrides +func ResetLevelOverrides() { + mu.Lock() + defer mu.Unlock() + overrides = make(map[string][]*levelOverride) +} + +// CurrentFormat returns the current log format +func CurrentFormat() Format { + mu.RLock() + defer mu.RUnlock() + return format +} + +// SetFormat sets a new log format +func SetFormat(newFormat Format) { + mu.Lock() + defer mu.Unlock() + format = newFormat + if newFormat == JSONFormat { + DisableDates() + } +} + +// SetOutput sets the log output writer +func SetOutput(w io.Writer) { + mu.Lock() + defer mu.Unlock() + output = &peekLogWriter{w} + if f, ok := w.(*os.File); ok { + filename = f.Name() + } else { + filename = "" + } + log.SetOutput(output) +} + +// File returns the log file, if any, or an empty string otherwise +func File() string { + mu.RLock() + defer mu.RUnlock() + return filename +} + +// IsFile returns true if the output is a non-default file +func IsFile() bool { + mu.RLock() + defer mu.RUnlock() + return filename != "" +} + +// DisableDates disables the date/time prefix +func DisableDates() { + log.SetFlags(0) +} + +// Loggable returns true if the given log level is lower or equal to the current log level +func Loggable(l Level) bool { + return CurrentLevel() <= l +} + +// IsTrace returns true if the current log level is TraceLevel +func IsTrace() bool { + return Loggable(TraceLevel) +} + +// IsDebug returns true if the current log level is DebugLevel or below +func IsDebug() bool { + return Loggable(DebugLevel) +} + +// peekLogWriter is an io.Writer which will peek at the rendered log event, +// and ensure that the rendered output is valid JSON. This is a hack! +type peekLogWriter struct { + w io.Writer +} + +func (w *peekLogWriter) Write(p []byte) (n int, err error) { + if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat { + return w.w.Write(p) + } + m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p))) + if m == "" { + return 0, nil + } + return w.w.Write([]byte(m + "\n")) +} diff --git a/log/log_test.go b/log/log_test.go new file mode 100644 index 00000000..d7ceb1c9 --- /dev/null +++ b/log/log_test.go @@ -0,0 +1,303 @@ +package log + +import ( + "bytes" + "encoding/json" + "github.com/stretchr/testify/require" + "io" + "log" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMain(m *testing.M) { + exitCode := m.Run() + resetState() + SetLevel(ErrorLevel) // For other modules! + os.Exit(exitCode) +} + +func TestLog_TagContextFieldFields(t *testing.T) { + t.Cleanup(resetState) + v := &fakeVisitor{ + UserID: "u_abc", + IP: "1.2.3.4", + } + err := &fakeError{ + Code: 123, + Message: "some error", + } + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + SetLevelOverride("tag", "stripe", DebugLevel) + SetLevelOverride("number", "5", DebugLevel) + + Tag("mytag"). + Field("field2", 123). + Field("field1", "value1"). + Time(time.Unix(123, 999000000).UTC()). + Info("hi there %s", "phil") + + Tag("not-stripe"). + Debug("this message will not appear") + + With(v). + Fields(Context{ + "stripe_customer_id": "acct_123", + "stripe_subscription_id": "sub_123", + }). + Tag("stripe"). + Err(err). + Time(time.Unix(456, 123000000).UTC()). + Debug("Subscription status %s", "active") + + Field("number", 5). + Time(time.Unix(777, 001000000).UTC()). + Debug("The number 5 is an int, but the level override is a string") + + expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"} +{"time":"1970-01-01T00:07:36.123Z","level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"} +{"time":"1970-01-01T00:12:57Z","level":"DEBUG","message":"The number 5 is an int, but the level override is a string","number":5} +` + require.Equal(t, expected, out.String()) +} + +func TestLog_NoAllocIfNotPrinted(t *testing.T) { + t.Cleanup(resetState) + v := &fakeVisitor{ + UserID: "u_abc", + IP: "1.2.3.4", + } + + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + + // Do not log, do not call contexters (because global level is INFO) + v.contextCalled = false + ev := With(v) + ev.Debug("some message") + require.False(t, v.contextCalled) + require.Equal(t, "", ev.Timestamp) + require.Equal(t, Level(0), ev.Level) + require.Equal(t, "", ev.Message) + require.Nil(t, ev.fields) + + // Logged because info level, contexters called + v.contextCalled = false + ev = With(v).Time(time.Unix(1111, 0).UTC()) + ev.Info("some message") + require.True(t, v.contextCalled) + require.NotNil(t, ev.fields) + require.Equal(t, "1.2.3.4", ev.fields["visitor_ip"]) + + // Not logged, but contexters called, because overrides exist + SetLevel(DebugLevel) + SetLevelOverride("tag", "overridetag", TraceLevel) + v.contextCalled = false + ev = Tag("sometag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC()) + ev.Trace("some debug message") + require.True(t, v.contextCalled) // If there are overrides, we must call the context to determine the filter fields + require.Equal(t, "", ev.Timestamp) + require.Equal(t, Level(0), ev.Level) + require.Equal(t, "", ev.Message) + require.Equal(t, 4, len(ev.fields)) + require.Equal(t, "value", ev.fields["field"]) + require.Equal(t, "sometag", ev.fields["tag"]) + + // Logged because of override tag, and contexters called + v.contextCalled = false + ev = Tag("overridetag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC()) + ev.Trace("some trace message") + require.True(t, v.contextCalled) + require.Equal(t, "1970-01-01T00:02:03Z", ev.Timestamp) + require.Equal(t, TraceLevel, ev.Level) + require.Equal(t, "some trace message", ev.Message) + + // Logged because of field override, and contexters called + ResetLevelOverrides() + SetLevelOverride("visitor_ip", "1.2.3.4", TraceLevel) + v.contextCalled = false + ev = With(v).Time(time.Unix(124, 0).UTC()) + ev.Trace("some trace message with override") + require.True(t, v.contextCalled) + require.Equal(t, "1970-01-01T00:02:04Z", ev.Timestamp) + require.Equal(t, TraceLevel, ev.Level) + require.Equal(t, "some trace message with override", ev.Message) + + expected := `{"time":"1970-01-01T00:18:31Z","level":"INFO","message":"some message","user_id":"u_abc","visitor_ip":"1.2.3.4"} +{"time":"1970-01-01T00:02:03Z","level":"TRACE","message":"some trace message","field":"value","tag":"overridetag","user_id":"u_abc","visitor_ip":"1.2.3.4"} +{"time":"1970-01-01T00:02:04Z","level":"TRACE","message":"some trace message with override","user_id":"u_abc","visitor_ip":"1.2.3.4"} +` + require.Equal(t, expected, out.String()) +} + +func TestLog_Timing(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + + Timing(func() { time.Sleep(300 * time.Millisecond) }). + Time(time.Unix(12, 0).UTC()). + Info("A thing that takes a while") + + var ev struct { + TimeTakenMs int64 `json:"time_taken_ms"` + } + require.Nil(t, json.Unmarshal(out.Bytes(), &ev)) + require.True(t, ev.TimeTakenMs >= 300) + require.Contains(t, out.String(), `{"time":"1970-01-01T00:00:12Z","level":"INFO","message":"A thing that takes a while","time_taken_ms":`) +} + +func TestLog_LevelOverrideAny(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + SetLevelOverride("this_one", "", DebugLevel) + SetLevelOverride("time_taken_ms", "", TraceLevel) + + Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Debug("this is logged") + Time(time.Unix(12, 0).UTC()).Field("not_this", "11").Debug("this is not logged") + Time(time.Unix(13, 0).UTC()).Field("this_too", "11").Info("this is also logged") + Time(time.Unix(14, 0).UTC()).Field("time_taken_ms", 0).Info("this is also logged") + + expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","this_one":"11"} +{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","this_too":"11"} +{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0} +` + require.Equal(t, expected, out.String()) + require.False(t, IsFile()) + require.Equal(t, "", File()) +} + +func TestLog_LevelOverride_ManyOnSameField(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + SetLevelOverride("tag", "manager", DebugLevel) + SetLevelOverride("tag", "publish", DebugLevel) + + Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged") + Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged") + Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged") + + expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"} +{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"} +` + require.Equal(t, expected, out.String()) + require.False(t, IsFile()) + require.Equal(t, "", File()) +} + +func TestLog_FieldIf(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetLevel(DebugLevel) + SetFormat(JSONFormat) + + Time(time.Unix(11, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // This is not logged + Field("tag", "manager"). + Debug("trace_field is not logged") + SetLevel(TraceLevel) + Time(time.Unix(12, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // Now it is logged + Field("tag", "manager"). + Debug("trace_field is logged") + + expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"} +{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"} +` + require.Equal(t, expected, out.String()) +} + +func TestLog_UsingStdLogger_JSON(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetFormat(JSONFormat) + + log.Println("Some other library is using the standard Go logger") + require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n") +} + +func TestLog_UsingStdLogger_Text(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + + log.Println("Some other library is using the standard Go logger") + require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n") + require.NotContains(t, out.String(), `{`) +} + +func TestLog_File(t *testing.T) { + t.Cleanup(resetState) + + logfile := filepath.Join(t.TempDir(), "ntfy.log") + f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600) + require.Nil(t, err) + SetOutput(f) + SetFormat(JSONFormat) + require.True(t, IsFile()) + require.Equal(t, logfile, File()) + + Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged") + require.Nil(t, f.Close()) + + f, err = os.Open(logfile) + require.Nil(t, err) + contents, err := io.ReadAll(f) + require.Nil(t, err) + require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents)) +} + +type fakeError struct { + Code int + Message string +} + +func (e fakeError) Error() string { + return e.Message +} + +func (e fakeError) Context() Context { + return Context{ + "error": e.Message, + "error_code": e.Code, + } +} + +type fakeVisitor struct { + UserID string + IP string + contextCalled bool +} + +func (v *fakeVisitor) Context() Context { + v.contextCalled = true + return Context{ + "user_id": v.UserID, + "visitor_ip": v.IP, + } +} + +func resetState() { + SetLevel(DefaultLevel) + SetFormat(DefaultFormat) + SetOutput(DefaultOutput) + ResetLevelOverrides() +} diff --git a/log/types.go b/log/types.go new file mode 100644 index 00000000..fd676371 --- /dev/null +++ b/log/types.go @@ -0,0 +1,115 @@ +package log + +import ( + "encoding/json" + "strings" +) + +// Level is a well-known log level, as defined below +type Level int + +// Well known log levels +const ( + TraceLevel Level = iota + DebugLevel + InfoLevel + WarnLevel + ErrorLevel + FatalLevel +) + +func (l Level) String() string { + switch l { + case TraceLevel: + return "TRACE" + case DebugLevel: + return "DEBUG" + case InfoLevel: + return "INFO" + case WarnLevel: + return "WARN" + case ErrorLevel: + return "ERROR" + case FatalLevel: + return "FATAL" + } + return "unknown" +} + +// MarshalJSON converts a level to a JSON string +func (l Level) MarshalJSON() ([]byte, error) { + return json.Marshal(l.String()) +} + +// ToLevel converts a string to a Level. It returns InfoLevel if the string +// does not match any known log levels. +func ToLevel(s string) Level { + switch strings.ToUpper(s) { + case "TRACE": + return TraceLevel + case "DEBUG": + return DebugLevel + case "INFO": + return InfoLevel + case "WARN", "WARNING": + return WarnLevel + case "ERROR": + return ErrorLevel + case "FATAL": + return FatalLevel + default: + return InfoLevel + } +} + +// Format is a well-known log format +type Format int + +// Log formats +const ( + TextFormat Format = iota + JSONFormat +) + +func (f Format) String() string { + switch f { + case TextFormat: + return "text" + case JSONFormat: + return "json" + } + return "unknown" +} + +// ToFormat converts a string to a Format. It returns TextFormat if the string +// does not match any known log formats. +func ToFormat(s string) Format { + switch strings.ToLower(s) { + case "text": + return TextFormat + case "json": + return JSONFormat + default: + return TextFormat + } +} + +// Contexter allows structs to export a key-value pairs in the form of a Context +type Contexter interface { + Context() Context +} + +// Context represents an object's state in the form of key-value pairs +type Context map[string]any + +// Merge merges other into this context +func (c Context) Merge(other Context) { + for k, v := range other { + c[k] = v + } +} + +type levelOverride struct { + value string + level Level +} diff --git a/main.go b/main.go index 5b1428d1..6aea6fa2 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,8 @@ package main import ( "fmt" + "git.zio.sh/astra/ntfy/v2/cmd" "github.com/urfave/cli/v2" - "heckel.io/ntfy/cmd" "os" "runtime" ) diff --git a/mkdocs.yml b/mkdocs.yml index 41e9acd4..7b14ee0c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,9 +9,11 @@ edit_uri: blob/main/docs/ theme: name: material + font: false language: en + custom_dir: docs/_overrides logo: static/img/ntfy.png - favicon: static/img/favicon.png + favicon: static/img/favicon.ico include_search_page: false search_index_only: true palette: @@ -61,11 +63,17 @@ markdown_extensions: custom_checkbox: true - attr_list - md_in_html + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg plugins: - search - minify: minify_html: true + - mkdocs-simple-hooks: + hooks: + on_post_build: "docs.hooks:copy_fonts" nav: - "Getting started": index.md @@ -73,7 +81,8 @@ nav: - "Sending messages": publish.md - "Subscribing": - "From your phone": subscribe/phone.md - - "From the Web UI": subscribe/web.md + - "From the Web app": subscribe/web.md + - "From the Desktop": subscribe/pwa.md - "From the CLI": subscribe/cli.md - "Using the API": subscribe/api.md - "Self-hosting": @@ -82,9 +91,12 @@ nav: - "Other things": - "FAQs": faq.md - "Examples": examples.md + - "Integrations + projects": integrations.md - "Release notes": releases.md - - "Deprecation notices": deprecations.md - "Emojis 🥳 🎉": emojis.md + - "Troubleshooting": troubleshooting.md + - "Known issues": known-issues.md + - "Deprecation notices": deprecations.md - "Development": develop.md - "Privacy policy": privacy.md diff --git a/requirements.txt b/requirements.txt index 9c2212a8..17b0fc1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # The documentation uses 'mkdocs', which is written in Python mkdocs-material mkdocs-minify-plugin +mkdocs-simple-hooks diff --git a/scripts/emoji-convert.sh b/scripts/emoji-convert.sh index 9817c884..8cbe397b 100755 --- a/scripts/emoji-convert.sh +++ b/scripts/emoji-convert.sh @@ -25,11 +25,11 @@ elif [[ "$1" == *.md ]]; then -You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically +You can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the -[tagging and emojis page](../publish/#tags-emojis). +[tagging and emojis page](publish.md#tags-emojis). - +
" > "$1" count="$(cat "$SCRIPTDIR/emoji.json" | jq -r '.[] | .emoji' | wc -l)" @@ -37,9 +37,9 @@ converted to emojis. This is a reference of all supported emojis. To learn more for col in 0 1 2; do from="$(($col * $percolumn + 1))" to="$(($col * $percolumn + 1 + $percolumn))" - echo "
" >> "$1" + echo "" >> "$1" done diff --git a/scripts/postinst.sh b/scripts/postinst.sh index e4ee165a..eae0b8a8 100755 --- a/scripts/postinst.sh +++ b/scripts/postinst.sh @@ -7,8 +7,9 @@ set -e if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then if [ -d /run/systemd/system ]; then # Create ntfy user/group - id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy - chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy + groupadd -f ntfy + id ntfy >/dev/null 2>&1 || useradd --system --no-create-home -g ntfy ntfy + chown ntfy:ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy # Hack to change permissions on cache file @@ -16,7 +17,7 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then if [ -f "$configfile" ]; then cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47 if [ -n "$cachefile" ]; then - chown ntfy.ntfy "$cachefile" || true + chown ntfy:ntfy "$cachefile" || true chmod 600 "$cachefile" || true fi fi diff --git a/server/actions.go b/server/actions.go index 89635c89..ce61395c 100644 --- a/server/actions.go +++ b/server/actions.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "heckel.io/ntfy/util" + "git.zio.sh/astra/ntfy/v2/util" "regexp" "strings" "unicode/utf8" @@ -60,13 +60,13 @@ func parseActions(s string) (actions []*action, err error) { return nil, fmt.Errorf("only %d actions allowed", actionsMax) } for _, action := range actions { - if !util.InStringList(actionsAll, action.Action) { + if !util.Contains(actionsAll, action.Action) { return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action) } else if action.Label == "" { return nil, fmt.Errorf("parameter 'label' is required") - } else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" { + } else if util.Contains(actionsWithURL, action.Action) && action.URL == "" { return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action) - } else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" { + } else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" { return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method) } } @@ -87,7 +87,8 @@ func parseActionsFromJSON(s string) ([]*action, error) { // https://ntfy.sh/docs/publish/#action-buttons), into an array of actions. // // It can parse an actions string like this: -// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ... +// +// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ... // // It works by advancing the position ("pos") through the input string ("input"). // @@ -96,10 +97,11 @@ func parseActionsFromJSON(s string) ([]*action, error) { // though it does not use state functions at all. // // Other resources: -// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html -// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go -// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go -// https://blog.gopheracademy.com/advent-2014/parsers-lexers/ +// +// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html +// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go +// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go +// https://blog.gopheracademy.com/advent-2014/parsers-lexers/ func parseActionsFromSimple(s string) ([]*action, error) { if !utf8.ValidString(s) { return nil, errors.New("invalid utf-8 string") @@ -154,7 +156,7 @@ func populateAction(newAction *action, section int, key, value string) error { key = "action" } else if key == "" && section == 1 { key = "label" - } else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) { + } else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) { key = "url" } @@ -176,7 +178,7 @@ func populateAction(newAction *action, section int, key, value string) error { newAction.Label = value case "clear": lvalue := strings.ToLower(value) - if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) { + if !util.Contains([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) { return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value) } newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1" @@ -186,6 +188,8 @@ func populateAction(newAction *action, section int, key, value string) error { newAction.Method = value case "body": newAction.Body = value + case "intent": + newAction.Intent = value default: return fmt.Errorf("key '%s' unknown", key) } diff --git a/server/actions_test.go b/server/actions_test.go index f75ac645..2a6c4b15 100644 --- a/server/actions_test.go +++ b/server/actions_test.go @@ -52,6 +52,14 @@ func TestParseActions(t *testing.T) { require.Equal(t, "some command", actions[0].Extras["command"]) require.Equal(t, "a parameter", actions[0].Extras["some_param"]) + // Broadcast action with intent + actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT") + require.Nil(t, err) + require.Equal(t, 1, len(actions)) + require.Equal(t, "broadcast", actions[0].Action) + require.Equal(t, "Do a thing", actions[0].Label) + require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent) + // Headers with dashes actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf") require.Nil(t, err) diff --git a/server/config.go b/server/config.go index e866e17a..c7b09082 100644 --- a/server/config.go +++ b/server/config.go @@ -1,19 +1,32 @@ package server import ( + "io/fs" + "net/netip" "time" + + "git.zio.sh/astra/ntfy/v2/user" ) // Defines default config settings (excluding limits, see below) const ( - DefaultListenHTTP = ":80" - DefaultCacheDuration = 12 * time.Hour - DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) - DefaultManagerInterval = time.Minute - DefaultAtSenderInterval = 10 * time.Second - DefaultMinDelay = 10 * time.Second - DefaultMaxDelay = 3 * 24 * time.Hour - DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery + DefaultListenHTTP = ":80" + DefaultCacheDuration = 12 * time.Hour + DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) + DefaultManagerInterval = time.Minute + DefaultDelayedSenderInterval = 10 * time.Second + DefaultMinDelay = 10 * time.Second + DefaultMaxDelay = 3 * 24 * time.Hour + DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery + DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs) + DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded" + DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed +) + +// Defines default Web Push settings +const ( + DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour + DefaultWebPushExpiryDuration = 9 * 24 * time.Hour ) // Defines all global and per-visitor limits @@ -38,35 +51,61 @@ const ( DefaultVisitorSubscriptionLimit = 30 DefaultVisitorRequestLimitBurst = 60 DefaultVisitorRequestLimitReplenish = 5 * time.Second + DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAccountCreationLimitBurst = 3 + DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour + DefaultVisitorAuthFailureLimitBurst = 30 + DefaultVisitorAuthFailureLimitReplenish = time.Minute DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB ) +var ( + // DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only) + DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) + + // DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be + // extended using the server.yml config. If updated, also update in Android and web app. + DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"} +) + // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { + File string // Config file, only used for testing BaseURL string ListenHTTP string ListenHTTPS string ListenUnix string + ListenUnixMode fs.FileMode KeyFile string CertFile string FirebaseKeyFile string CacheFile string CacheDuration time.Duration + CacheStartupQueries string + CacheBatchSize int + CacheBatchTimeout time.Duration AuthFile string - AuthDefaultRead bool - AuthDefaultWrite bool + AuthStartupQueries string + AuthDefault user.Permission + AuthBcryptCost int + AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration KeepaliveInterval time.Duration ManagerInterval time.Duration - WebRootIsApp bool - AtSenderInterval time.Duration + DisallowedTopics []string + WebRoot string // empty to disable + DelayedSenderInterval time.Duration FirebaseKeepaliveInterval time.Duration + FirebasePollInterval time.Duration + FirebaseQuotaExceededPenaltyDuration time.Duration + UpstreamBaseURL string + UpstreamAccessToken string SMTPSenderAddr string SMTPSenderUser string SMTPSenderPass string @@ -74,6 +113,15 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioAccount string + TwilioAuthToken string + TwilioPhoneNumber string + TwilioCallsBaseURL string + TwilioVerifyBaseURL string + TwilioVerifyService string + MetricsEnable bool + MetricsListenHTTP string + ProfileListenHTTP string MessageLimit int MinDelay time.Duration MaxDelay time.Duration @@ -81,50 +129,123 @@ type Config struct { TotalAttachmentSizeLimit int64 VisitorSubscriptionLimit int VisitorAttachmentTotalSizeLimit int64 - VisitorAttachmentDailyBandwidthLimit int + VisitorAttachmentDailyBandwidthLimit int64 VisitorRequestLimitBurst int VisitorRequestLimitReplenish time.Duration - VisitorRequestExemptIPAddrs []string + VisitorRequestExemptIPAddrs []netip.Prefix + VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorAccountCreationLimitBurst int + VisitorAccountCreationLimitReplenish time.Duration + VisitorAuthFailureLimitBurst int + VisitorAuthFailureLimitReplenish time.Duration + VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats + VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics BehindProxy bool + StripeSecretKey string + StripeWebhookKey string + StripePriceCacheDuration time.Duration + BillingContact string + EnableSignup bool // Enable creation of accounts via API and UI + EnableLogin bool + EnableReservations bool // Allow users with role "user" to own/reserve topics + EnableMetrics bool + AccessControlAllowOrigin string // CORS header field to restrict access from web clients + Version string // injected by App + WebPushPrivateKey string + WebPushPublicKey string + WebPushFile string + WebPushEmailAddress string + WebPushStartupQueries string + WebPushExpiryDuration time.Duration + WebPushExpiryWarningDuration time.Duration } // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ + File: "", // Only used for testing BaseURL: "", ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", ListenUnix: "", + ListenUnixMode: 0, KeyFile: "", CertFile: "", FirebaseKeyFile: "", CacheFile: "", CacheDuration: DefaultCacheDuration, + CacheStartupQueries: "", + CacheBatchSize: 0, + CacheBatchTimeout: 0, AuthFile: "", - AuthDefaultRead: true, - AuthDefaultWrite: true, + AuthStartupQueries: "", + AuthDefault: user.PermissionReadWrite, + AuthBcryptCost: user.DefaultUserPasswordBcryptCost, + AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, AttachmentCacheDir: "", AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, + DisallowedTopics: DefaultDisallowedTopics, + WebRoot: "/", + DelayedSenderInterval: DefaultDelayedSenderInterval, + FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, + FirebasePollInterval: DefaultFirebasePollInterval, + FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration, + UpstreamBaseURL: "", + UpstreamAccessToken: "", + SMTPSenderAddr: "", + SMTPSenderUser: "", + SMTPSenderPass: "", + SMTPSenderFrom: "", + SMTPServerListen: "", + SMTPServerDomain: "", + SMTPServerAddrPrefix: "", + TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests + TwilioAccount: "", + TwilioAuthToken: "", + TwilioPhoneNumber: "", + TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests + TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, - AtSenderInterval: DefaultAtSenderInterval, - FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, TotalTopicLimit: DefaultTotalTopicLimit, + TotalAttachmentSizeLimit: 0, VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorRequestExemptIPAddrs: make([]string, 0), + VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0), + VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst, + VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish, + VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst, + VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish, + VisitorStatsResetTime: DefaultVisitorStatsResetTime, + VisitorSubscriberRateLimiting: false, BehindProxy: false, + StripeSecretKey: "", + StripeWebhookKey: "", + StripePriceCacheDuration: DefaultStripePriceCacheDuration, + BillingContact: "", + EnableSignup: false, + EnableLogin: false, + EnableReservations: false, + AccessControlAllowOrigin: "*", + Version: "", + WebPushPrivateKey: "", + WebPushPublicKey: "", + WebPushFile: "", + WebPushEmailAddress: "", + WebPushExpiryDuration: DefaultWebPushExpiryDuration, + WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, } } diff --git a/server/config_test.go b/server/config_test.go index 14f028f1..23fbadf1 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -1,8 +1,8 @@ package server_test import ( + "git.zio.sh/astra/ntfy/v2/server" "github.com/stretchr/testify/assert" - "heckel.io/ntfy/server" "testing" ) diff --git a/server/errors.go b/server/errors.go index 32c1b3b9..f3d0d6b1 100644 --- a/server/errors.go +++ b/server/errors.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "fmt" + "git.zio.sh/astra/ntfy/v2/log" "net/http" ) @@ -12,6 +13,7 @@ type errHTTP struct { HTTPCode int `json:"http"` Message string `json:"error"` Link string `json:"link,omitempty"` + context log.Context } func (e errHTTP) Error() string { @@ -23,42 +25,122 @@ func (e errHTTP) JSON() string { return string(b) } -func wrapErrHTTP(err *errHTTP, message string, args ...interface{}) *errHTTP { - return &errHTTP{ - Code: err.Code, - HTTPCode: err.HTTPCode, - Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)), - Link: err.Link, +func (e errHTTP) Context() log.Context { + context := log.Context{ + "error": e.Message, + "error_code": e.Code, + "http_status": e.HTTPCode, + } + for k, v := range e.context { + context[k] = v + } + return context +} + +func (e errHTTP) Wrap(message string, args ...any) *errHTTP { + clone := e.clone() + clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...)) + return &clone +} + +func (e errHTTP) With(contexters ...log.Contexter) *errHTTP { + c := e.clone() + if c.context == nil { + c.context = make(log.Context) + } + for _, contexter := range contexters { + c.context.Merge(contexter.Context()) + } + return &c +} + +func (e errHTTP) Fields(context log.Context) *errHTTP { + c := e.clone() + if c.context == nil { + c.context = make(log.Context) + } + c.context.Merge(context) + return &c +} + +func (e errHTTP) clone() errHTTP { + context := make(log.Context) + for k, v := range e.context { + context[k] = v + } + return errHTTP{ + Code: e.Code, + HTTPCode: e.HTTPCode, + Message: e.Message, + Link: e.Link, + context: context, } } var ( - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} - errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} - errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"} - errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} - errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} - errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} - errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} - errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil} + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil} + errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil} + errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} + errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil} + errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil} + errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil} + errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil} + errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil} + errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil} + errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil} + errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil} + errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil} + errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil} + errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil} + errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil} + errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} + errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} + errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} + errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil} + errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} + errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} + errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} + errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} + errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} + errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} + errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil} + errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} + errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} + errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} + errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} + errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} + errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} + errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} + errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} + errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} + errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil} + errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil} ) diff --git a/server/example.html b/server/example.html deleted file mode 100644 index e558ef12..00000000 --- a/server/example.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - ntfy.sh: EventSource Example - - - - -

ntfy.sh: EventSource Example

-

- This is an example showing how to use ntfy.sh with - EventSource.
- This example doesn't need a server. You can just save the HTML page and run it from anywhere. -

- -

Log:

-
- - - - - diff --git a/server/file_cache.go b/server/file_cache.go index ad4961cc..499cca16 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -2,7 +2,9 @@ package server import ( "errors" - "heckel.io/ntfy/util" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" "io" "os" "path/filepath" @@ -11,7 +13,7 @@ import ( ) var ( - fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) + fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength)) errInvalidFileID = errors.New("invalid file ID") errFileExists = errors.New("file exists") ) @@ -20,11 +22,10 @@ type fileCache struct { dir string totalSizeCurrent int64 totalSizeLimit int64 - fileSizeLimit int64 mu sync.Mutex } -func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileCache, error) { +func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) { if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } @@ -36,7 +37,6 @@ func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileC dir: dir, totalSizeCurrent: size, totalSizeLimit: totalSizeLimit, - fileSizeLimit: fileSizeLimit, }, nil } @@ -44,6 +44,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in if !fileIDRegex.MatchString(id) { return 0, errInvalidFileID } + log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment") file := filepath.Join(c.dir, id) if _, err := os.Stat(file); err == nil { return 0, errFileExists @@ -53,7 +54,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in return 0, err } defer f.Close() - limiters = append(limiters, util.NewFixedLimiter(c.Remaining()), util.NewFixedLimiter(c.fileSizeLimit)) + limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) limitWriter := util.NewLimitWriter(f, limiters...) size, err := io.Copy(limitWriter, in) if err != nil { @@ -66,6 +67,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in } c.mu.Lock() c.totalSizeCurrent += size + mset(metricAttachmentsTotalSize, c.totalSizeCurrent) c.mu.Unlock() return size, nil } @@ -75,8 +77,11 @@ func (c *fileCache) Remove(ids ...string) error { if !fileIDRegex.MatchString(id) { return errInvalidFileID } + log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment") file := filepath.Join(c.dir, id) - _ = os.Remove(file) // Best effort delete + if err := os.Remove(file); err != nil { + log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment") + } } size, err := dirSize(c.dir) if err != nil { @@ -85,6 +90,7 @@ func (c *fileCache) Remove(ids ...string) error { c.mu.Lock() c.totalSizeCurrent = size c.mu.Unlock() + mset(metricAttachmentsTotalSize, size) return nil } diff --git a/server/file_cache_test.go b/server/file_cache_test.go index 36d1d1a3..cdb534be 100644 --- a/server/file_cache_test.go +++ b/server/file_cache_test.go @@ -3,8 +3,8 @@ package server import ( "bytes" "fmt" + "git.zio.sh/astra/ntfy/v2/util" "github.com/stretchr/testify/require" - "heckel.io/ntfy/util" "os" "strings" "testing" @@ -16,10 +16,10 @@ var ( func TestFileCache_Write_Success(t *testing.T) { dir, c := newTestFileCache(t) - size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999)) + size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) require.Nil(t, err) require.Equal(t, int64(11), size) - require.Equal(t, "normal file", readFile(t, dir+"/abc")) + require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) require.Equal(t, int64(11), c.Size()) require.Equal(t, int64(10229), c.Remaining()) } @@ -27,18 +27,18 @@ func TestFileCache_Write_Success(t *testing.T) { func TestFileCache_Write_Remove_Success(t *testing.T) { dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024) for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999))) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) require.Nil(t, err) require.Equal(t, int64(999), size) } require.Equal(t, int64(9990), c.Size()) require.Equal(t, int64(250), c.Remaining()) - require.FileExists(t, dir+"/abc1") - require.FileExists(t, dir+"/abc5") + require.FileExists(t, dir+"/abcdefghijk1") + require.FileExists(t, dir+"/abcdefghijk5") - require.Nil(t, c.Remove("abc1", "abc5")) - require.NoFileExists(t, dir+"/abc1") - require.NoFileExists(t, dir+"/abc5") + require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) + require.NoFileExists(t, dir+"/abcdefghijk1") + require.NoFileExists(t, dir+"/abcdefghijk5") require.Equal(t, int64(7992), c.Size()) require.Equal(t, int64(2248), c.Remaining()) } @@ -46,32 +46,25 @@ func TestFileCache_Write_Remove_Success(t *testing.T) { func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) { dir, c := newTestFileCache(t) for i := 0; i < 10; i++ { - size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray)) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) require.Nil(t, err) require.Equal(t, int64(1024), size) } - _, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray)) + _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc11") -} - -func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) { - dir, c := newTestFileCache(t) - _, err := c.Write("abc", bytes.NewReader(make([]byte, 1025))) - require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc") + require.NoFileExists(t, dir+"/abcdefghijkX") } func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { dir, c := newTestFileCache(t) - _, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc") + require.NoFileExists(t, dir+"/abcdefghijkl") } func newTestFileCache(t *testing.T) (dir string, cache *fileCache) { dir = t.TempDir() - cache, err := newFileCache(dir, 10*1024, 1*1024) + cache, err := newFileCache(dir, 10*1024) require.Nil(t, err) return dir, cache } diff --git a/server/log.go b/server/log.go new file mode 100644 index 00000000..23fdf5d5 --- /dev/null +++ b/server/log.go @@ -0,0 +1,125 @@ +package server + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/emersion/go-smtp" + "github.com/gorilla/websocket" + "net/http" + "strings" + "unicode/utf8" +) + +// Log tags +const ( + tagStartup = "startup" + tagHTTP = "http" + tagPublish = "publish" + tagSubscribe = "subscribe" + tagFirebase = "firebase" + tagSMTP = "smtp" // Receive email + tagEmail = "email" // Send email + tagTwilio = "twilio" + tagFileCache = "file_cache" + tagMessageCache = "message_cache" + tagStripe = "stripe" + tagAccount = "account" + tagManager = "manager" + tagResetter = "resetter" + tagWebsocket = "websocket" + tagMatrix = "matrix" + tagWebPush = "webpush" +) + +var ( + normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage} + rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge} +) + +// logr creates a new log event with HTTP request fields +func logr(r *http.Request) *log.Event { + return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten +} + +// logv creates a new log event with visitor fields +func logv(v *visitor) *log.Event { + return log.With(v) +} + +// logvr creates a new log event with HTTP request and visitor fields +func logvr(v *visitor, r *http.Request) *log.Event { + return logr(r).With(v) +} + +// logvrm creates a new log event with HTTP request, visitor fields and message fields +func logvrm(v *visitor, r *http.Request, m *message) *log.Event { + return logvr(v, r).With(m) +} + +// logvrm creates a new log event with visitor fields and message fields +func logvm(v *visitor, m *message) *log.Event { + return logv(v).With(m) +} + +// logem creates a new log event with email fields +func logem(smtpConn *smtp.Conn) *log.Event { + ev := log.Tag(tagSMTP).Field("smtp_hostname", smtpConn.Hostname()) + if smtpConn.Conn() != nil { + ev.Field("smtp_remote_addr", smtpConn.Conn().RemoteAddr().String()) + } + return ev +} + +func httpContext(r *http.Request) log.Context { + requestURI := r.RequestURI + if requestURI == "" { + requestURI = r.URL.Path + } + return log.Context{ + "http_method": r.Method, + "http_path": requestURI, + } +} + +func websocketErrorContext(err error) log.Context { + if c, ok := err.(*websocket.CloseError); ok { + return log.Context{ + "error": c.Error(), + "error_code": c.Code, + "error_type": "websocket.CloseError", + } + } + return log.Context{ + "error": err.Error(), + } +} + +func renderHTTPRequest(r *http.Request) string { + peekLimit := 4096 + lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) + for key, values := range r.Header { + for _, value := range values { + lines += fmt.Sprintf("%s: %s\n", key, value) + } + } + lines += "\n" + body, err := util.Peek(r.Body, peekLimit) + if err != nil { + lines = fmt.Sprintf("(could not read body: %s)\n", err.Error()) + } else if utf8.Valid(body.PeekedBytes) { + lines += string(body.PeekedBytes) + if body.LimitReached { + lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit) + } + lines += "\n" + } else { + if body.LimitReached { + lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes) + } else { + lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes) + } + } + r.Body = body // Important: Reset body, so it can be re-read + return strings.TrimSpace(lines) +} diff --git a/server/mailer_emoji.json b/server/mailer_emoji.json deleted file mode 100644 index 4d4c32fc..00000000 --- a/server/mailer_emoji.json +++ /dev/null @@ -1 +0,0 @@ -[{"emoji":"😀","aliases":["grinning"]},{"emoji":"😃","aliases":["smiley"]},{"emoji":"😄","aliases":["smile"]},{"emoji":"😁","aliases":["grin"]},{"emoji":"😆","aliases":["laughing","satisfied"]},{"emoji":"😅","aliases":["sweat_smile"]},{"emoji":"🤣","aliases":["rofl"]},{"emoji":"😂","aliases":["joy"]},{"emoji":"🙂","aliases":["slightly_smiling_face"]},{"emoji":"🙃","aliases":["upside_down_face"]},{"emoji":"😉","aliases":["wink"]},{"emoji":"😊","aliases":["blush"]},{"emoji":"😇","aliases":["innocent"]},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"]},{"emoji":"😍","aliases":["heart_eyes"]},{"emoji":"🤩","aliases":["star_struck"]},{"emoji":"😘","aliases":["kissing_heart"]},{"emoji":"😗","aliases":["kissing"]},{"emoji":"☺️","aliases":["relaxed"]},{"emoji":"😚","aliases":["kissing_closed_eyes"]},{"emoji":"😙","aliases":["kissing_smiling_eyes"]},{"emoji":"🥲","aliases":["smiling_face_with_tear"]},{"emoji":"😋","aliases":["yum"]},{"emoji":"😛","aliases":["stuck_out_tongue"]},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"]},{"emoji":"🤪","aliases":["zany_face"]},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"]},{"emoji":"🤑","aliases":["money_mouth_face"]},{"emoji":"🤗","aliases":["hugs"]},{"emoji":"🤭","aliases":["hand_over_mouth"]},{"emoji":"🤫","aliases":["shushing_face"]},{"emoji":"🤔","aliases":["thinking"]},{"emoji":"🤐","aliases":["zipper_mouth_face"]},{"emoji":"🤨","aliases":["raised_eyebrow"]},{"emoji":"😐","aliases":["neutral_face"]},{"emoji":"😑","aliases":["expressionless"]},{"emoji":"😶","aliases":["no_mouth"]},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"]},{"emoji":"😏","aliases":["smirk"]},{"emoji":"😒","aliases":["unamused"]},{"emoji":"🙄","aliases":["roll_eyes"]},{"emoji":"😬","aliases":["grimacing"]},{"emoji":"😮‍💨","aliases":["face_exhaling"]},{"emoji":"🤥","aliases":["lying_face"]},{"emoji":"😌","aliases":["relieved"]},{"emoji":"😔","aliases":["pensive"]},{"emoji":"😪","aliases":["sleepy"]},{"emoji":"🤤","aliases":["drooling_face"]},{"emoji":"😴","aliases":["sleeping"]},{"emoji":"😷","aliases":["mask"]},{"emoji":"🤒","aliases":["face_with_thermometer"]},{"emoji":"🤕","aliases":["face_with_head_bandage"]},{"emoji":"🤢","aliases":["nauseated_face"]},{"emoji":"🤮","aliases":["vomiting_face"]},{"emoji":"🤧","aliases":["sneezing_face"]},{"emoji":"🥵","aliases":["hot_face"]},{"emoji":"🥶","aliases":["cold_face"]},{"emoji":"🥴","aliases":["woozy_face"]},{"emoji":"😵","aliases":["dizzy_face"]},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"]},{"emoji":"🤯","aliases":["exploding_head"]},{"emoji":"🤠","aliases":["cowboy_hat_face"]},{"emoji":"🥳","aliases":["partying_face"]},{"emoji":"🥸","aliases":["disguised_face"]},{"emoji":"😎","aliases":["sunglasses"]},{"emoji":"🤓","aliases":["nerd_face"]},{"emoji":"🧐","aliases":["monocle_face"]},{"emoji":"😕","aliases":["confused"]},{"emoji":"😟","aliases":["worried"]},{"emoji":"🙁","aliases":["slightly_frowning_face"]},{"emoji":"☹️","aliases":["frowning_face"]},{"emoji":"😮","aliases":["open_mouth"]},{"emoji":"😯","aliases":["hushed"]},{"emoji":"😲","aliases":["astonished"]},{"emoji":"😳","aliases":["flushed"]},{"emoji":"🥺","aliases":["pleading_face"]},{"emoji":"😦","aliases":["frowning"]},{"emoji":"😧","aliases":["anguished"]},{"emoji":"😨","aliases":["fearful"]},{"emoji":"😰","aliases":["cold_sweat"]},{"emoji":"😥","aliases":["disappointed_relieved"]},{"emoji":"😢","aliases":["cry"]},{"emoji":"😭","aliases":["sob"]},{"emoji":"😱","aliases":["scream"]},{"emoji":"😖","aliases":["confounded"]},{"emoji":"😣","aliases":["persevere"]},{"emoji":"😞","aliases":["disappointed"]},{"emoji":"😓","aliases":["sweat"]},{"emoji":"😩","aliases":["weary"]},{"emoji":"😫","aliases":["tired_face"]},{"emoji":"🥱","aliases":["yawning_face"]},{"emoji":"😤","aliases":["triumph"]},{"emoji":"😡","aliases":["rage","pout"]},{"emoji":"😠","aliases":["angry"]},{"emoji":"🤬","aliases":["cursing_face"]},{"emoji":"😈","aliases":["smiling_imp"]},{"emoji":"👿","aliases":["imp"]},{"emoji":"💀","aliases":["skull"]},{"emoji":"☠️","aliases":["skull_and_crossbones"]},{"emoji":"💩","aliases":["hankey","poop","shit"]},{"emoji":"🤡","aliases":["clown_face"]},{"emoji":"👹","aliases":["japanese_ogre"]},{"emoji":"👺","aliases":["japanese_goblin"]},{"emoji":"👻","aliases":["ghost"]},{"emoji":"👽","aliases":["alien"]},{"emoji":"👾","aliases":["space_invader"]},{"emoji":"🤖","aliases":["robot"]},{"emoji":"😺","aliases":["smiley_cat"]},{"emoji":"😸","aliases":["smile_cat"]},{"emoji":"😹","aliases":["joy_cat"]},{"emoji":"😻","aliases":["heart_eyes_cat"]},{"emoji":"😼","aliases":["smirk_cat"]},{"emoji":"😽","aliases":["kissing_cat"]},{"emoji":"🙀","aliases":["scream_cat"]},{"emoji":"😿","aliases":["crying_cat_face"]},{"emoji":"😾","aliases":["pouting_cat"]},{"emoji":"🙈","aliases":["see_no_evil"]},{"emoji":"🙉","aliases":["hear_no_evil"]},{"emoji":"🙊","aliases":["speak_no_evil"]},{"emoji":"💋","aliases":["kiss"]},{"emoji":"💌","aliases":["love_letter"]},{"emoji":"💘","aliases":["cupid"]},{"emoji":"💝","aliases":["gift_heart"]},{"emoji":"💖","aliases":["sparkling_heart"]},{"emoji":"💗","aliases":["heartpulse"]},{"emoji":"💓","aliases":["heartbeat"]},{"emoji":"💞","aliases":["revolving_hearts"]},{"emoji":"💕","aliases":["two_hearts"]},{"emoji":"💟","aliases":["heart_decoration"]},{"emoji":"❣️","aliases":["heavy_heart_exclamation"]},{"emoji":"💔","aliases":["broken_heart"]},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"]},{"emoji":"❤️‍🩹","aliases":["mending_heart"]},{"emoji":"❤️","aliases":["heart"]},{"emoji":"🧡","aliases":["orange_heart"]},{"emoji":"💛","aliases":["yellow_heart"]},{"emoji":"💚","aliases":["green_heart"]},{"emoji":"💙","aliases":["blue_heart"]},{"emoji":"💜","aliases":["purple_heart"]},{"emoji":"🤎","aliases":["brown_heart"]},{"emoji":"🖤","aliases":["black_heart"]},{"emoji":"🤍","aliases":["white_heart"]},{"emoji":"💯","aliases":["100"]},{"emoji":"💢","aliases":["anger"]},{"emoji":"💥","aliases":["boom","collision"]},{"emoji":"💫","aliases":["dizzy"]},{"emoji":"💦","aliases":["sweat_drops"]},{"emoji":"💨","aliases":["dash"]},{"emoji":"🕳️","aliases":["hole"]},{"emoji":"💣","aliases":["bomb"]},{"emoji":"💬","aliases":["speech_balloon"]},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"]},{"emoji":"🗨️","aliases":["left_speech_bubble"]},{"emoji":"🗯️","aliases":["right_anger_bubble"]},{"emoji":"💭","aliases":["thought_balloon"]},{"emoji":"💤","aliases":["zzz"]},{"emoji":"👋","aliases":["wave"]},{"emoji":"🤚","aliases":["raised_back_of_hand"]},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"]},{"emoji":"✋","aliases":["hand","raised_hand"]},{"emoji":"🖖","aliases":["vulcan_salute"]},{"emoji":"👌","aliases":["ok_hand"]},{"emoji":"🤌","aliases":["pinched_fingers"]},{"emoji":"🤏","aliases":["pinching_hand"]},{"emoji":"✌️","aliases":["v"]},{"emoji":"🤞","aliases":["crossed_fingers"]},{"emoji":"🤟","aliases":["love_you_gesture"]},{"emoji":"🤘","aliases":["metal"]},{"emoji":"🤙","aliases":["call_me_hand"]},{"emoji":"👈","aliases":["point_left"]},{"emoji":"👉","aliases":["point_right"]},{"emoji":"👆","aliases":["point_up_2"]},{"emoji":"🖕","aliases":["middle_finger","fu"]},{"emoji":"👇","aliases":["point_down"]},{"emoji":"☝️","aliases":["point_up"]},{"emoji":"👍","aliases":["+1","thumbsup"]},{"emoji":"👎","aliases":["-1","thumbsdown"]},{"emoji":"✊","aliases":["fist_raised","fist"]},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"]},{"emoji":"🤛","aliases":["fist_left"]},{"emoji":"🤜","aliases":["fist_right"]},{"emoji":"👏","aliases":["clap"]},{"emoji":"🙌","aliases":["raised_hands"]},{"emoji":"👐","aliases":["open_hands"]},{"emoji":"🤲","aliases":["palms_up_together"]},{"emoji":"🤝","aliases":["handshake"]},{"emoji":"🙏","aliases":["pray"]},{"emoji":"✍️","aliases":["writing_hand"]},{"emoji":"💅","aliases":["nail_care"]},{"emoji":"🤳","aliases":["selfie"]},{"emoji":"💪","aliases":["muscle"]},{"emoji":"🦾","aliases":["mechanical_arm"]},{"emoji":"🦿","aliases":["mechanical_leg"]},{"emoji":"🦵","aliases":["leg"]},{"emoji":"🦶","aliases":["foot"]},{"emoji":"👂","aliases":["ear"]},{"emoji":"🦻","aliases":["ear_with_hearing_aid"]},{"emoji":"👃","aliases":["nose"]},{"emoji":"🧠","aliases":["brain"]},{"emoji":"🫀","aliases":["anatomical_heart"]},{"emoji":"🫁","aliases":["lungs"]},{"emoji":"🦷","aliases":["tooth"]},{"emoji":"🦴","aliases":["bone"]},{"emoji":"👀","aliases":["eyes"]},{"emoji":"👁️","aliases":["eye"]},{"emoji":"👅","aliases":["tongue"]},{"emoji":"👄","aliases":["lips"]},{"emoji":"👶","aliases":["baby"]},{"emoji":"🧒","aliases":["child"]},{"emoji":"👦","aliases":["boy"]},{"emoji":"👧","aliases":["girl"]},{"emoji":"🧑","aliases":["adult"]},{"emoji":"👱","aliases":["blond_haired_person"]},{"emoji":"👨","aliases":["man"]},{"emoji":"🧔","aliases":["bearded_person"]},{"emoji":"🧔‍♂️","aliases":["man_beard"]},{"emoji":"🧔‍♀️","aliases":["woman_beard"]},{"emoji":"👨‍🦰","aliases":["red_haired_man"]},{"emoji":"👨‍🦱","aliases":["curly_haired_man"]},{"emoji":"👨‍🦳","aliases":["white_haired_man"]},{"emoji":"👨‍🦲","aliases":["bald_man"]},{"emoji":"👩","aliases":["woman"]},{"emoji":"👩‍🦰","aliases":["red_haired_woman"]},{"emoji":"🧑‍🦰","aliases":["person_red_hair"]},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"]},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"]},{"emoji":"👩‍🦳","aliases":["white_haired_woman"]},{"emoji":"🧑‍🦳","aliases":["person_white_hair"]},{"emoji":"👩‍🦲","aliases":["bald_woman"]},{"emoji":"🧑‍🦲","aliases":["person_bald"]},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"]},{"emoji":"👱‍♂️","aliases":["blond_haired_man"]},{"emoji":"🧓","aliases":["older_adult"]},{"emoji":"👴","aliases":["older_man"]},{"emoji":"👵","aliases":["older_woman"]},{"emoji":"🙍","aliases":["frowning_person"]},{"emoji":"🙍‍♂️","aliases":["frowning_man"]},{"emoji":"🙍‍♀️","aliases":["frowning_woman"]},{"emoji":"🙎","aliases":["pouting_face"]},{"emoji":"🙎‍♂️","aliases":["pouting_man"]},{"emoji":"🙎‍♀️","aliases":["pouting_woman"]},{"emoji":"🙅","aliases":["no_good"]},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"]},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"]},{"emoji":"🙆","aliases":["ok_person"]},{"emoji":"🙆‍♂️","aliases":["ok_man"]},{"emoji":"🙆‍♀️","aliases":["ok_woman"]},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"]},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"]},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"]},{"emoji":"🙋","aliases":["raising_hand"]},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"]},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"]},{"emoji":"🧏","aliases":["deaf_person"]},{"emoji":"🧏‍♂️","aliases":["deaf_man"]},{"emoji":"🧏‍♀️","aliases":["deaf_woman"]},{"emoji":"🙇","aliases":["bow"]},{"emoji":"🙇‍♂️","aliases":["bowing_man"]},{"emoji":"🙇‍♀️","aliases":["bowing_woman"]},{"emoji":"🤦","aliases":["facepalm"]},{"emoji":"🤦‍♂️","aliases":["man_facepalming"]},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"]},{"emoji":"🤷","aliases":["shrug"]},{"emoji":"🤷‍♂️","aliases":["man_shrugging"]},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"]},{"emoji":"🧑‍⚕️","aliases":["health_worker"]},{"emoji":"👨‍⚕️","aliases":["man_health_worker"]},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"]},{"emoji":"🧑‍🎓","aliases":["student"]},{"emoji":"👨‍🎓","aliases":["man_student"]},{"emoji":"👩‍🎓","aliases":["woman_student"]},{"emoji":"🧑‍🏫","aliases":["teacher"]},{"emoji":"👨‍🏫","aliases":["man_teacher"]},{"emoji":"👩‍🏫","aliases":["woman_teacher"]},{"emoji":"🧑‍⚖️","aliases":["judge"]},{"emoji":"👨‍⚖️","aliases":["man_judge"]},{"emoji":"👩‍⚖️","aliases":["woman_judge"]},{"emoji":"🧑‍🌾","aliases":["farmer"]},{"emoji":"👨‍🌾","aliases":["man_farmer"]},{"emoji":"👩‍🌾","aliases":["woman_farmer"]},{"emoji":"🧑‍🍳","aliases":["cook"]},{"emoji":"👨‍🍳","aliases":["man_cook"]},{"emoji":"👩‍🍳","aliases":["woman_cook"]},{"emoji":"🧑‍🔧","aliases":["mechanic"]},{"emoji":"👨‍🔧","aliases":["man_mechanic"]},{"emoji":"👩‍🔧","aliases":["woman_mechanic"]},{"emoji":"🧑‍🏭","aliases":["factory_worker"]},{"emoji":"👨‍🏭","aliases":["man_factory_worker"]},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"]},{"emoji":"🧑‍💼","aliases":["office_worker"]},{"emoji":"👨‍💼","aliases":["man_office_worker"]},{"emoji":"👩‍💼","aliases":["woman_office_worker"]},{"emoji":"🧑‍🔬","aliases":["scientist"]},{"emoji":"👨‍🔬","aliases":["man_scientist"]},{"emoji":"👩‍🔬","aliases":["woman_scientist"]},{"emoji":"🧑‍💻","aliases":["technologist"]},{"emoji":"👨‍💻","aliases":["man_technologist"]},{"emoji":"👩‍💻","aliases":["woman_technologist"]},{"emoji":"🧑‍🎤","aliases":["singer"]},{"emoji":"👨‍🎤","aliases":["man_singer"]},{"emoji":"👩‍🎤","aliases":["woman_singer"]},{"emoji":"🧑‍🎨","aliases":["artist"]},{"emoji":"👨‍🎨","aliases":["man_artist"]},{"emoji":"👩‍🎨","aliases":["woman_artist"]},{"emoji":"🧑‍✈️","aliases":["pilot"]},{"emoji":"👨‍✈️","aliases":["man_pilot"]},{"emoji":"👩‍✈️","aliases":["woman_pilot"]},{"emoji":"🧑‍🚀","aliases":["astronaut"]},{"emoji":"👨‍🚀","aliases":["man_astronaut"]},{"emoji":"👩‍🚀","aliases":["woman_astronaut"]},{"emoji":"🧑‍🚒","aliases":["firefighter"]},{"emoji":"👨‍🚒","aliases":["man_firefighter"]},{"emoji":"👩‍🚒","aliases":["woman_firefighter"]},{"emoji":"👮","aliases":["police_officer","cop"]},{"emoji":"👮‍♂️","aliases":["policeman"]},{"emoji":"👮‍♀️","aliases":["policewoman"]},{"emoji":"🕵️","aliases":["detective"]},{"emoji":"🕵️‍♂️","aliases":["male_detective"]},{"emoji":"🕵️‍♀️","aliases":["female_detective"]},{"emoji":"💂","aliases":["guard"]},{"emoji":"💂‍♂️","aliases":["guardsman"]},{"emoji":"💂‍♀️","aliases":["guardswoman"]},{"emoji":"🥷","aliases":["ninja"]},{"emoji":"👷","aliases":["construction_worker"]},{"emoji":"👷‍♂️","aliases":["construction_worker_man"]},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"]},{"emoji":"🤴","aliases":["prince"]},{"emoji":"👸","aliases":["princess"]},{"emoji":"👳","aliases":["person_with_turban"]},{"emoji":"👳‍♂️","aliases":["man_with_turban"]},{"emoji":"👳‍♀️","aliases":["woman_with_turban"]},{"emoji":"👲","aliases":["man_with_gua_pi_mao"]},{"emoji":"🧕","aliases":["woman_with_headscarf"]},{"emoji":"🤵","aliases":["person_in_tuxedo"]},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"]},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"]},{"emoji":"👰","aliases":["person_with_veil"]},{"emoji":"👰‍♂️","aliases":["man_with_veil"]},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"]},{"emoji":"🤰","aliases":["pregnant_woman"]},{"emoji":"🤱","aliases":["breast_feeding"]},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"]},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"]},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"]},{"emoji":"👼","aliases":["angel"]},{"emoji":"🎅","aliases":["santa"]},{"emoji":"🤶","aliases":["mrs_claus"]},{"emoji":"🧑‍🎄","aliases":["mx_claus"]},{"emoji":"🦸","aliases":["superhero"]},{"emoji":"🦸‍♂️","aliases":["superhero_man"]},{"emoji":"🦸‍♀️","aliases":["superhero_woman"]},{"emoji":"🦹","aliases":["supervillain"]},{"emoji":"🦹‍♂️","aliases":["supervillain_man"]},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"]},{"emoji":"🧙","aliases":["mage"]},{"emoji":"🧙‍♂️","aliases":["mage_man"]},{"emoji":"🧙‍♀️","aliases":["mage_woman"]},{"emoji":"🧚","aliases":["fairy"]},{"emoji":"🧚‍♂️","aliases":["fairy_man"]},{"emoji":"🧚‍♀️","aliases":["fairy_woman"]},{"emoji":"🧛","aliases":["vampire"]},{"emoji":"🧛‍♂️","aliases":["vampire_man"]},{"emoji":"🧛‍♀️","aliases":["vampire_woman"]},{"emoji":"🧜","aliases":["merperson"]},{"emoji":"🧜‍♂️","aliases":["merman"]},{"emoji":"🧜‍♀️","aliases":["mermaid"]},{"emoji":"🧝","aliases":["elf"]},{"emoji":"🧝‍♂️","aliases":["elf_man"]},{"emoji":"🧝‍♀️","aliases":["elf_woman"]},{"emoji":"🧞","aliases":["genie"]},{"emoji":"🧞‍♂️","aliases":["genie_man"]},{"emoji":"🧞‍♀️","aliases":["genie_woman"]},{"emoji":"🧟","aliases":["zombie"]},{"emoji":"🧟‍♂️","aliases":["zombie_man"]},{"emoji":"🧟‍♀️","aliases":["zombie_woman"]},{"emoji":"💆","aliases":["massage"]},{"emoji":"💆‍♂️","aliases":["massage_man"]},{"emoji":"💆‍♀️","aliases":["massage_woman"]},{"emoji":"💇","aliases":["haircut"]},{"emoji":"💇‍♂️","aliases":["haircut_man"]},{"emoji":"💇‍♀️","aliases":["haircut_woman"]},{"emoji":"🚶","aliases":["walking"]},{"emoji":"🚶‍♂️","aliases":["walking_man"]},{"emoji":"🚶‍♀️","aliases":["walking_woman"]},{"emoji":"🧍","aliases":["standing_person"]},{"emoji":"🧍‍♂️","aliases":["standing_man"]},{"emoji":"🧍‍♀️","aliases":["standing_woman"]},{"emoji":"🧎","aliases":["kneeling_person"]},{"emoji":"🧎‍♂️","aliases":["kneeling_man"]},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"]},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"]},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"]},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"]},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"]},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"]},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"]},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"]},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"]},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"]},{"emoji":"🏃","aliases":["runner","running"]},{"emoji":"🏃‍♂️","aliases":["running_man"]},{"emoji":"🏃‍♀️","aliases":["running_woman"]},{"emoji":"💃","aliases":["woman_dancing","dancer"]},{"emoji":"🕺","aliases":["man_dancing"]},{"emoji":"🕴️","aliases":["business_suit_levitating"]},{"emoji":"👯","aliases":["dancers"]},{"emoji":"👯‍♂️","aliases":["dancing_men"]},{"emoji":"👯‍♀️","aliases":["dancing_women"]},{"emoji":"🧖","aliases":["sauna_person"]},{"emoji":"🧖‍♂️","aliases":["sauna_man"]},{"emoji":"🧖‍♀️","aliases":["sauna_woman"]},{"emoji":"🧗","aliases":["climbing"]},{"emoji":"🧗‍♂️","aliases":["climbing_man"]},{"emoji":"🧗‍♀️","aliases":["climbing_woman"]},{"emoji":"🤺","aliases":["person_fencing"]},{"emoji":"🏇","aliases":["horse_racing"]},{"emoji":"⛷️","aliases":["skier"]},{"emoji":"🏂","aliases":["snowboarder"]},{"emoji":"🏌️","aliases":["golfing"]},{"emoji":"🏌️‍♂️","aliases":["golfing_man"]},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"]},{"emoji":"🏄","aliases":["surfer"]},{"emoji":"🏄‍♂️","aliases":["surfing_man"]},{"emoji":"🏄‍♀️","aliases":["surfing_woman"]},{"emoji":"🚣","aliases":["rowboat"]},{"emoji":"🚣‍♂️","aliases":["rowing_man"]},{"emoji":"🚣‍♀️","aliases":["rowing_woman"]},{"emoji":"🏊","aliases":["swimmer"]},{"emoji":"🏊‍♂️","aliases":["swimming_man"]},{"emoji":"🏊‍♀️","aliases":["swimming_woman"]},{"emoji":"⛹️","aliases":["bouncing_ball_person"]},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"]},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"]},{"emoji":"🏋️","aliases":["weight_lifting"]},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"]},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"]},{"emoji":"🚴","aliases":["bicyclist"]},{"emoji":"🚴‍♂️","aliases":["biking_man"]},{"emoji":"🚴‍♀️","aliases":["biking_woman"]},{"emoji":"🚵","aliases":["mountain_bicyclist"]},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"]},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"]},{"emoji":"🤸","aliases":["cartwheeling"]},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"]},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"]},{"emoji":"🤼","aliases":["wrestling"]},{"emoji":"🤼‍♂️","aliases":["men_wrestling"]},{"emoji":"🤼‍♀️","aliases":["women_wrestling"]},{"emoji":"🤽","aliases":["water_polo"]},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"]},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"]},{"emoji":"🤾","aliases":["handball_person"]},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"]},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"]},{"emoji":"🤹","aliases":["juggling_person"]},{"emoji":"🤹‍♂️","aliases":["man_juggling"]},{"emoji":"🤹‍♀️","aliases":["woman_juggling"]},{"emoji":"🧘","aliases":["lotus_position"]},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"]},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"]},{"emoji":"🛀","aliases":["bath"]},{"emoji":"🛌","aliases":["sleeping_bed"]},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"]},{"emoji":"👭","aliases":["two_women_holding_hands"]},{"emoji":"👫","aliases":["couple"]},{"emoji":"👬","aliases":["two_men_holding_hands"]},{"emoji":"💏","aliases":["couplekiss"]},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"]},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"]},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"]},{"emoji":"💑","aliases":["couple_with_heart"]},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"]},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"]},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"]},{"emoji":"👪","aliases":["family"]},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"]},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"]},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"]},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"]},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"]},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"]},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"]},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"]},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"]},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"]},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"]},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"]},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"]},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"]},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"]},{"emoji":"👨‍👦","aliases":["family_man_boy"]},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"]},{"emoji":"👨‍👧","aliases":["family_man_girl"]},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"]},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"]},{"emoji":"👩‍👦","aliases":["family_woman_boy"]},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"]},{"emoji":"👩‍👧","aliases":["family_woman_girl"]},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"]},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"]},{"emoji":"🗣️","aliases":["speaking_head"]},{"emoji":"👤","aliases":["bust_in_silhouette"]},{"emoji":"👥","aliases":["busts_in_silhouette"]},{"emoji":"🫂","aliases":["people_hugging"]},{"emoji":"👣","aliases":["footprints"]},{"emoji":"🐵","aliases":["monkey_face"]},{"emoji":"🐒","aliases":["monkey"]},{"emoji":"🦍","aliases":["gorilla"]},{"emoji":"🦧","aliases":["orangutan"]},{"emoji":"🐶","aliases":["dog"]},{"emoji":"🐕","aliases":["dog2"]},{"emoji":"🦮","aliases":["guide_dog"]},{"emoji":"🐕‍🦺","aliases":["service_dog"]},{"emoji":"🐩","aliases":["poodle"]},{"emoji":"🐺","aliases":["wolf"]},{"emoji":"🦊","aliases":["fox_face"]},{"emoji":"🦝","aliases":["raccoon"]},{"emoji":"🐱","aliases":["cat"]},{"emoji":"🐈","aliases":["cat2"]},{"emoji":"🐈‍⬛","aliases":["black_cat"]},{"emoji":"🦁","aliases":["lion"]},{"emoji":"🐯","aliases":["tiger"]},{"emoji":"🐅","aliases":["tiger2"]},{"emoji":"🐆","aliases":["leopard"]},{"emoji":"🐴","aliases":["horse"]},{"emoji":"🐎","aliases":["racehorse"]},{"emoji":"🦄","aliases":["unicorn"]},{"emoji":"🦓","aliases":["zebra"]},{"emoji":"🦌","aliases":["deer"]},{"emoji":"🦬","aliases":["bison"]},{"emoji":"🐮","aliases":["cow"]},{"emoji":"🐂","aliases":["ox"]},{"emoji":"🐃","aliases":["water_buffalo"]},{"emoji":"🐄","aliases":["cow2"]},{"emoji":"🐷","aliases":["pig"]},{"emoji":"🐖","aliases":["pig2"]},{"emoji":"🐗","aliases":["boar"]},{"emoji":"🐽","aliases":["pig_nose"]},{"emoji":"🐏","aliases":["ram"]},{"emoji":"🐑","aliases":["sheep"]},{"emoji":"🐐","aliases":["goat"]},{"emoji":"🐪","aliases":["dromedary_camel"]},{"emoji":"🐫","aliases":["camel"]},{"emoji":"🦙","aliases":["llama"]},{"emoji":"🦒","aliases":["giraffe"]},{"emoji":"🐘","aliases":["elephant"]},{"emoji":"🦣","aliases":["mammoth"]},{"emoji":"🦏","aliases":["rhinoceros"]},{"emoji":"🦛","aliases":["hippopotamus"]},{"emoji":"🐭","aliases":["mouse"]},{"emoji":"🐁","aliases":["mouse2"]},{"emoji":"🐀","aliases":["rat"]},{"emoji":"🐹","aliases":["hamster"]},{"emoji":"🐰","aliases":["rabbit"]},{"emoji":"🐇","aliases":["rabbit2"]},{"emoji":"🐿️","aliases":["chipmunk"]},{"emoji":"🦫","aliases":["beaver"]},{"emoji":"🦔","aliases":["hedgehog"]},{"emoji":"🦇","aliases":["bat"]},{"emoji":"🐻","aliases":["bear"]},{"emoji":"🐻‍❄️","aliases":["polar_bear"]},{"emoji":"🐨","aliases":["koala"]},{"emoji":"🐼","aliases":["panda_face"]},{"emoji":"🦥","aliases":["sloth"]},{"emoji":"🦦","aliases":["otter"]},{"emoji":"🦨","aliases":["skunk"]},{"emoji":"🦘","aliases":["kangaroo"]},{"emoji":"🦡","aliases":["badger"]},{"emoji":"🐾","aliases":["feet","paw_prints"]},{"emoji":"🦃","aliases":["turkey"]},{"emoji":"🐔","aliases":["chicken"]},{"emoji":"🐓","aliases":["rooster"]},{"emoji":"🐣","aliases":["hatching_chick"]},{"emoji":"🐤","aliases":["baby_chick"]},{"emoji":"🐥","aliases":["hatched_chick"]},{"emoji":"🐦","aliases":["bird"]},{"emoji":"🐧","aliases":["penguin"]},{"emoji":"🕊️","aliases":["dove"]},{"emoji":"🦅","aliases":["eagle"]},{"emoji":"🦆","aliases":["duck"]},{"emoji":"🦢","aliases":["swan"]},{"emoji":"🦉","aliases":["owl"]},{"emoji":"🦤","aliases":["dodo"]},{"emoji":"🪶","aliases":["feather"]},{"emoji":"🦩","aliases":["flamingo"]},{"emoji":"🦚","aliases":["peacock"]},{"emoji":"🦜","aliases":["parrot"]},{"emoji":"🐸","aliases":["frog"]},{"emoji":"🐊","aliases":["crocodile"]},{"emoji":"🐢","aliases":["turtle"]},{"emoji":"🦎","aliases":["lizard"]},{"emoji":"🐍","aliases":["snake"]},{"emoji":"🐲","aliases":["dragon_face"]},{"emoji":"🐉","aliases":["dragon"]},{"emoji":"🦕","aliases":["sauropod"]},{"emoji":"🦖","aliases":["t-rex"]},{"emoji":"🐳","aliases":["whale"]},{"emoji":"🐋","aliases":["whale2"]},{"emoji":"🐬","aliases":["dolphin","flipper"]},{"emoji":"🦭","aliases":["seal"]},{"emoji":"🐟","aliases":["fish"]},{"emoji":"🐠","aliases":["tropical_fish"]},{"emoji":"🐡","aliases":["blowfish"]},{"emoji":"🦈","aliases":["shark"]},{"emoji":"🐙","aliases":["octopus"]},{"emoji":"🐚","aliases":["shell"]},{"emoji":"🐌","aliases":["snail"]},{"emoji":"🦋","aliases":["butterfly"]},{"emoji":"🐛","aliases":["bug"]},{"emoji":"🐜","aliases":["ant"]},{"emoji":"🐝","aliases":["bee","honeybee"]},{"emoji":"🪲","aliases":["beetle"]},{"emoji":"🐞","aliases":["lady_beetle"]},{"emoji":"🦗","aliases":["cricket"]},{"emoji":"🪳","aliases":["cockroach"]},{"emoji":"🕷️","aliases":["spider"]},{"emoji":"🕸️","aliases":["spider_web"]},{"emoji":"🦂","aliases":["scorpion"]},{"emoji":"🦟","aliases":["mosquito"]},{"emoji":"🪰","aliases":["fly"]},{"emoji":"🪱","aliases":["worm"]},{"emoji":"🦠","aliases":["microbe"]},{"emoji":"💐","aliases":["bouquet"]},{"emoji":"🌸","aliases":["cherry_blossom"]},{"emoji":"💮","aliases":["white_flower"]},{"emoji":"🏵️","aliases":["rosette"]},{"emoji":"🌹","aliases":["rose"]},{"emoji":"🥀","aliases":["wilted_flower"]},{"emoji":"🌺","aliases":["hibiscus"]},{"emoji":"🌻","aliases":["sunflower"]},{"emoji":"🌼","aliases":["blossom"]},{"emoji":"🌷","aliases":["tulip"]},{"emoji":"🌱","aliases":["seedling"]},{"emoji":"🪴","aliases":["potted_plant"]},{"emoji":"🌲","aliases":["evergreen_tree"]},{"emoji":"🌳","aliases":["deciduous_tree"]},{"emoji":"🌴","aliases":["palm_tree"]},{"emoji":"🌵","aliases":["cactus"]},{"emoji":"🌾","aliases":["ear_of_rice"]},{"emoji":"🌿","aliases":["herb"]},{"emoji":"☘️","aliases":["shamrock"]},{"emoji":"🍀","aliases":["four_leaf_clover"]},{"emoji":"🍁","aliases":["maple_leaf"]},{"emoji":"🍂","aliases":["fallen_leaf"]},{"emoji":"🍃","aliases":["leaves"]},{"emoji":"🍇","aliases":["grapes"]},{"emoji":"🍈","aliases":["melon"]},{"emoji":"🍉","aliases":["watermelon"]},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"]},{"emoji":"🍋","aliases":["lemon"]},{"emoji":"🍌","aliases":["banana"]},{"emoji":"🍍","aliases":["pineapple"]},{"emoji":"🥭","aliases":["mango"]},{"emoji":"🍎","aliases":["apple"]},{"emoji":"🍏","aliases":["green_apple"]},{"emoji":"🍐","aliases":["pear"]},{"emoji":"🍑","aliases":["peach"]},{"emoji":"🍒","aliases":["cherries"]},{"emoji":"🍓","aliases":["strawberry"]},{"emoji":"🫐","aliases":["blueberries"]},{"emoji":"🥝","aliases":["kiwi_fruit"]},{"emoji":"🍅","aliases":["tomato"]},{"emoji":"🫒","aliases":["olive"]},{"emoji":"🥥","aliases":["coconut"]},{"emoji":"🥑","aliases":["avocado"]},{"emoji":"🍆","aliases":["eggplant"]},{"emoji":"🥔","aliases":["potato"]},{"emoji":"🥕","aliases":["carrot"]},{"emoji":"🌽","aliases":["corn"]},{"emoji":"🌶️","aliases":["hot_pepper"]},{"emoji":"🫑","aliases":["bell_pepper"]},{"emoji":"🥒","aliases":["cucumber"]},{"emoji":"🥬","aliases":["leafy_green"]},{"emoji":"🥦","aliases":["broccoli"]},{"emoji":"🧄","aliases":["garlic"]},{"emoji":"🧅","aliases":["onion"]},{"emoji":"🍄","aliases":["mushroom"]},{"emoji":"🥜","aliases":["peanuts"]},{"emoji":"🌰","aliases":["chestnut"]},{"emoji":"🍞","aliases":["bread"]},{"emoji":"🥐","aliases":["croissant"]},{"emoji":"🥖","aliases":["baguette_bread"]},{"emoji":"🫓","aliases":["flatbread"]},{"emoji":"🥨","aliases":["pretzel"]},{"emoji":"🥯","aliases":["bagel"]},{"emoji":"🥞","aliases":["pancakes"]},{"emoji":"🧇","aliases":["waffle"]},{"emoji":"🧀","aliases":["cheese"]},{"emoji":"🍖","aliases":["meat_on_bone"]},{"emoji":"🍗","aliases":["poultry_leg"]},{"emoji":"🥩","aliases":["cut_of_meat"]},{"emoji":"🥓","aliases":["bacon"]},{"emoji":"🍔","aliases":["hamburger"]},{"emoji":"🍟","aliases":["fries"]},{"emoji":"🍕","aliases":["pizza"]},{"emoji":"🌭","aliases":["hotdog"]},{"emoji":"🥪","aliases":["sandwich"]},{"emoji":"🌮","aliases":["taco"]},{"emoji":"🌯","aliases":["burrito"]},{"emoji":"🫔","aliases":["tamale"]},{"emoji":"🥙","aliases":["stuffed_flatbread"]},{"emoji":"🧆","aliases":["falafel"]},{"emoji":"🥚","aliases":["egg"]},{"emoji":"🍳","aliases":["fried_egg"]},{"emoji":"🥘","aliases":["shallow_pan_of_food"]},{"emoji":"🍲","aliases":["stew"]},{"emoji":"🫕","aliases":["fondue"]},{"emoji":"🥣","aliases":["bowl_with_spoon"]},{"emoji":"🥗","aliases":["green_salad"]},{"emoji":"🍿","aliases":["popcorn"]},{"emoji":"🧈","aliases":["butter"]},{"emoji":"🧂","aliases":["salt"]},{"emoji":"🥫","aliases":["canned_food"]},{"emoji":"🍱","aliases":["bento"]},{"emoji":"🍘","aliases":["rice_cracker"]},{"emoji":"🍙","aliases":["rice_ball"]},{"emoji":"🍚","aliases":["rice"]},{"emoji":"🍛","aliases":["curry"]},{"emoji":"🍜","aliases":["ramen"]},{"emoji":"🍝","aliases":["spaghetti"]},{"emoji":"🍠","aliases":["sweet_potato"]},{"emoji":"🍢","aliases":["oden"]},{"emoji":"🍣","aliases":["sushi"]},{"emoji":"🍤","aliases":["fried_shrimp"]},{"emoji":"🍥","aliases":["fish_cake"]},{"emoji":"🥮","aliases":["moon_cake"]},{"emoji":"🍡","aliases":["dango"]},{"emoji":"🥟","aliases":["dumpling"]},{"emoji":"🥠","aliases":["fortune_cookie"]},{"emoji":"🥡","aliases":["takeout_box"]},{"emoji":"🦀","aliases":["crab"]},{"emoji":"🦞","aliases":["lobster"]},{"emoji":"🦐","aliases":["shrimp"]},{"emoji":"🦑","aliases":["squid"]},{"emoji":"🦪","aliases":["oyster"]},{"emoji":"🍦","aliases":["icecream"]},{"emoji":"🍧","aliases":["shaved_ice"]},{"emoji":"🍨","aliases":["ice_cream"]},{"emoji":"🍩","aliases":["doughnut"]},{"emoji":"🍪","aliases":["cookie"]},{"emoji":"🎂","aliases":["birthday"]},{"emoji":"🍰","aliases":["cake"]},{"emoji":"🧁","aliases":["cupcake"]},{"emoji":"🥧","aliases":["pie"]},{"emoji":"🍫","aliases":["chocolate_bar"]},{"emoji":"🍬","aliases":["candy"]},{"emoji":"🍭","aliases":["lollipop"]},{"emoji":"🍮","aliases":["custard"]},{"emoji":"🍯","aliases":["honey_pot"]},{"emoji":"🍼","aliases":["baby_bottle"]},{"emoji":"🥛","aliases":["milk_glass"]},{"emoji":"☕","aliases":["coffee"]},{"emoji":"🫖","aliases":["teapot"]},{"emoji":"🍵","aliases":["tea"]},{"emoji":"🍶","aliases":["sake"]},{"emoji":"🍾","aliases":["champagne"]},{"emoji":"🍷","aliases":["wine_glass"]},{"emoji":"🍸","aliases":["cocktail"]},{"emoji":"🍹","aliases":["tropical_drink"]},{"emoji":"🍺","aliases":["beer"]},{"emoji":"🍻","aliases":["beers"]},{"emoji":"🥂","aliases":["clinking_glasses"]},{"emoji":"🥃","aliases":["tumbler_glass"]},{"emoji":"🥤","aliases":["cup_with_straw"]},{"emoji":"🧋","aliases":["bubble_tea"]},{"emoji":"🧃","aliases":["beverage_box"]},{"emoji":"🧉","aliases":["mate"]},{"emoji":"🧊","aliases":["ice_cube"]},{"emoji":"🥢","aliases":["chopsticks"]},{"emoji":"🍽️","aliases":["plate_with_cutlery"]},{"emoji":"🍴","aliases":["fork_and_knife"]},{"emoji":"🥄","aliases":["spoon"]},{"emoji":"🔪","aliases":["hocho","knife"]},{"emoji":"🏺","aliases":["amphora"]},{"emoji":"🌍","aliases":["earth_africa"]},{"emoji":"🌎","aliases":["earth_americas"]},{"emoji":"🌏","aliases":["earth_asia"]},{"emoji":"🌐","aliases":["globe_with_meridians"]},{"emoji":"🗺️","aliases":["world_map"]},{"emoji":"🗾","aliases":["japan"]},{"emoji":"🧭","aliases":["compass"]},{"emoji":"🏔️","aliases":["mountain_snow"]},{"emoji":"⛰️","aliases":["mountain"]},{"emoji":"🌋","aliases":["volcano"]},{"emoji":"🗻","aliases":["mount_fuji"]},{"emoji":"🏕️","aliases":["camping"]},{"emoji":"🏖️","aliases":["beach_umbrella"]},{"emoji":"🏜️","aliases":["desert"]},{"emoji":"🏝️","aliases":["desert_island"]},{"emoji":"🏞️","aliases":["national_park"]},{"emoji":"🏟️","aliases":["stadium"]},{"emoji":"🏛️","aliases":["classical_building"]},{"emoji":"🏗️","aliases":["building_construction"]},{"emoji":"🧱","aliases":["bricks"]},{"emoji":"🪨","aliases":["rock"]},{"emoji":"🪵","aliases":["wood"]},{"emoji":"🛖","aliases":["hut"]},{"emoji":"🏘️","aliases":["houses"]},{"emoji":"🏚️","aliases":["derelict_house"]},{"emoji":"🏠","aliases":["house"]},{"emoji":"🏡","aliases":["house_with_garden"]},{"emoji":"🏢","aliases":["office"]},{"emoji":"🏣","aliases":["post_office"]},{"emoji":"🏤","aliases":["european_post_office"]},{"emoji":"🏥","aliases":["hospital"]},{"emoji":"🏦","aliases":["bank"]},{"emoji":"🏨","aliases":["hotel"]},{"emoji":"🏩","aliases":["love_hotel"]},{"emoji":"🏪","aliases":["convenience_store"]},{"emoji":"🏫","aliases":["school"]},{"emoji":"🏬","aliases":["department_store"]},{"emoji":"🏭","aliases":["factory"]},{"emoji":"🏯","aliases":["japanese_castle"]},{"emoji":"🏰","aliases":["european_castle"]},{"emoji":"💒","aliases":["wedding"]},{"emoji":"🗼","aliases":["tokyo_tower"]},{"emoji":"🗽","aliases":["statue_of_liberty"]},{"emoji":"⛪","aliases":["church"]},{"emoji":"🕌","aliases":["mosque"]},{"emoji":"🛕","aliases":["hindu_temple"]},{"emoji":"🕍","aliases":["synagogue"]},{"emoji":"⛩️","aliases":["shinto_shrine"]},{"emoji":"🕋","aliases":["kaaba"]},{"emoji":"⛲","aliases":["fountain"]},{"emoji":"⛺","aliases":["tent"]},{"emoji":"🌁","aliases":["foggy"]},{"emoji":"🌃","aliases":["night_with_stars"]},{"emoji":"🏙️","aliases":["cityscape"]},{"emoji":"🌄","aliases":["sunrise_over_mountains"]},{"emoji":"🌅","aliases":["sunrise"]},{"emoji":"🌆","aliases":["city_sunset"]},{"emoji":"🌇","aliases":["city_sunrise"]},{"emoji":"🌉","aliases":["bridge_at_night"]},{"emoji":"♨️","aliases":["hotsprings"]},{"emoji":"🎠","aliases":["carousel_horse"]},{"emoji":"🎡","aliases":["ferris_wheel"]},{"emoji":"🎢","aliases":["roller_coaster"]},{"emoji":"💈","aliases":["barber"]},{"emoji":"🎪","aliases":["circus_tent"]},{"emoji":"🚂","aliases":["steam_locomotive"]},{"emoji":"🚃","aliases":["railway_car"]},{"emoji":"🚄","aliases":["bullettrain_side"]},{"emoji":"🚅","aliases":["bullettrain_front"]},{"emoji":"🚆","aliases":["train2"]},{"emoji":"🚇","aliases":["metro"]},{"emoji":"🚈","aliases":["light_rail"]},{"emoji":"🚉","aliases":["station"]},{"emoji":"🚊","aliases":["tram"]},{"emoji":"🚝","aliases":["monorail"]},{"emoji":"🚞","aliases":["mountain_railway"]},{"emoji":"🚋","aliases":["train"]},{"emoji":"🚌","aliases":["bus"]},{"emoji":"🚍","aliases":["oncoming_bus"]},{"emoji":"🚎","aliases":["trolleybus"]},{"emoji":"🚐","aliases":["minibus"]},{"emoji":"🚑","aliases":["ambulance"]},{"emoji":"🚒","aliases":["fire_engine"]},{"emoji":"🚓","aliases":["police_car"]},{"emoji":"🚔","aliases":["oncoming_police_car"]},{"emoji":"🚕","aliases":["taxi"]},{"emoji":"🚖","aliases":["oncoming_taxi"]},{"emoji":"🚗","aliases":["car","red_car"]},{"emoji":"🚘","aliases":["oncoming_automobile"]},{"emoji":"🚙","aliases":["blue_car"]},{"emoji":"🛻","aliases":["pickup_truck"]},{"emoji":"🚚","aliases":["truck"]},{"emoji":"🚛","aliases":["articulated_lorry"]},{"emoji":"🚜","aliases":["tractor"]},{"emoji":"🏎️","aliases":["racing_car"]},{"emoji":"🏍️","aliases":["motorcycle"]},{"emoji":"🛵","aliases":["motor_scooter"]},{"emoji":"🦽","aliases":["manual_wheelchair"]},{"emoji":"🦼","aliases":["motorized_wheelchair"]},{"emoji":"🛺","aliases":["auto_rickshaw"]},{"emoji":"🚲","aliases":["bike"]},{"emoji":"🛴","aliases":["kick_scooter"]},{"emoji":"🛹","aliases":["skateboard"]},{"emoji":"🛼","aliases":["roller_skate"]},{"emoji":"🚏","aliases":["busstop"]},{"emoji":"🛣️","aliases":["motorway"]},{"emoji":"🛤️","aliases":["railway_track"]},{"emoji":"🛢️","aliases":["oil_drum"]},{"emoji":"⛽","aliases":["fuelpump"]},{"emoji":"🚨","aliases":["rotating_light"]},{"emoji":"🚥","aliases":["traffic_light"]},{"emoji":"🚦","aliases":["vertical_traffic_light"]},{"emoji":"🛑","aliases":["stop_sign"]},{"emoji":"🚧","aliases":["construction"]},{"emoji":"⚓","aliases":["anchor"]},{"emoji":"⛵","aliases":["boat","sailboat"]},{"emoji":"🛶","aliases":["canoe"]},{"emoji":"🚤","aliases":["speedboat"]},{"emoji":"🛳️","aliases":["passenger_ship"]},{"emoji":"⛴️","aliases":["ferry"]},{"emoji":"🛥️","aliases":["motor_boat"]},{"emoji":"🚢","aliases":["ship"]},{"emoji":"✈️","aliases":["airplane"]},{"emoji":"🛩️","aliases":["small_airplane"]},{"emoji":"🛫","aliases":["flight_departure"]},{"emoji":"🛬","aliases":["flight_arrival"]},{"emoji":"🪂","aliases":["parachute"]},{"emoji":"💺","aliases":["seat"]},{"emoji":"🚁","aliases":["helicopter"]},{"emoji":"🚟","aliases":["suspension_railway"]},{"emoji":"🚠","aliases":["mountain_cableway"]},{"emoji":"🚡","aliases":["aerial_tramway"]},{"emoji":"🛰️","aliases":["artificial_satellite"]},{"emoji":"🚀","aliases":["rocket"]},{"emoji":"🛸","aliases":["flying_saucer"]},{"emoji":"🛎️","aliases":["bellhop_bell"]},{"emoji":"🧳","aliases":["luggage"]},{"emoji":"⌛","aliases":["hourglass"]},{"emoji":"⏳","aliases":["hourglass_flowing_sand"]},{"emoji":"⌚","aliases":["watch"]},{"emoji":"⏰","aliases":["alarm_clock"]},{"emoji":"⏱️","aliases":["stopwatch"]},{"emoji":"⏲️","aliases":["timer_clock"]},{"emoji":"🕰️","aliases":["mantelpiece_clock"]},{"emoji":"🕛","aliases":["clock12"]},{"emoji":"🕧","aliases":["clock1230"]},{"emoji":"🕐","aliases":["clock1"]},{"emoji":"🕜","aliases":["clock130"]},{"emoji":"🕑","aliases":["clock2"]},{"emoji":"🕝","aliases":["clock230"]},{"emoji":"🕒","aliases":["clock3"]},{"emoji":"🕞","aliases":["clock330"]},{"emoji":"🕓","aliases":["clock4"]},{"emoji":"🕟","aliases":["clock430"]},{"emoji":"🕔","aliases":["clock5"]},{"emoji":"🕠","aliases":["clock530"]},{"emoji":"🕕","aliases":["clock6"]},{"emoji":"🕡","aliases":["clock630"]},{"emoji":"🕖","aliases":["clock7"]},{"emoji":"🕢","aliases":["clock730"]},{"emoji":"🕗","aliases":["clock8"]},{"emoji":"🕣","aliases":["clock830"]},{"emoji":"🕘","aliases":["clock9"]},{"emoji":"🕤","aliases":["clock930"]},{"emoji":"🕙","aliases":["clock10"]},{"emoji":"🕥","aliases":["clock1030"]},{"emoji":"🕚","aliases":["clock11"]},{"emoji":"🕦","aliases":["clock1130"]},{"emoji":"🌑","aliases":["new_moon"]},{"emoji":"🌒","aliases":["waxing_crescent_moon"]},{"emoji":"🌓","aliases":["first_quarter_moon"]},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"]},{"emoji":"🌕","aliases":["full_moon"]},{"emoji":"🌖","aliases":["waning_gibbous_moon"]},{"emoji":"🌗","aliases":["last_quarter_moon"]},{"emoji":"🌘","aliases":["waning_crescent_moon"]},{"emoji":"🌙","aliases":["crescent_moon"]},{"emoji":"🌚","aliases":["new_moon_with_face"]},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"]},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"]},{"emoji":"🌡️","aliases":["thermometer"]},{"emoji":"☀️","aliases":["sunny"]},{"emoji":"🌝","aliases":["full_moon_with_face"]},{"emoji":"🌞","aliases":["sun_with_face"]},{"emoji":"🪐","aliases":["ringed_planet"]},{"emoji":"⭐","aliases":["star"]},{"emoji":"🌟","aliases":["star2"]},{"emoji":"🌠","aliases":["stars"]},{"emoji":"🌌","aliases":["milky_way"]},{"emoji":"☁️","aliases":["cloud"]},{"emoji":"⛅","aliases":["partly_sunny"]},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"]},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"]},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"]},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"]},{"emoji":"🌧️","aliases":["cloud_with_rain"]},{"emoji":"🌨️","aliases":["cloud_with_snow"]},{"emoji":"🌩️","aliases":["cloud_with_lightning"]},{"emoji":"🌪️","aliases":["tornado"]},{"emoji":"🌫️","aliases":["fog"]},{"emoji":"🌬️","aliases":["wind_face"]},{"emoji":"🌀","aliases":["cyclone"]},{"emoji":"🌈","aliases":["rainbow"]},{"emoji":"🌂","aliases":["closed_umbrella"]},{"emoji":"☂️","aliases":["open_umbrella"]},{"emoji":"☔","aliases":["umbrella"]},{"emoji":"⛱️","aliases":["parasol_on_ground"]},{"emoji":"⚡","aliases":["zap"]},{"emoji":"❄️","aliases":["snowflake"]},{"emoji":"☃️","aliases":["snowman_with_snow"]},{"emoji":"⛄","aliases":["snowman"]},{"emoji":"☄️","aliases":["comet"]},{"emoji":"🔥","aliases":["fire"]},{"emoji":"💧","aliases":["droplet"]},{"emoji":"🌊","aliases":["ocean"]},{"emoji":"🎃","aliases":["jack_o_lantern"]},{"emoji":"🎄","aliases":["christmas_tree"]},{"emoji":"🎆","aliases":["fireworks"]},{"emoji":"🎇","aliases":["sparkler"]},{"emoji":"🧨","aliases":["firecracker"]},{"emoji":"✨","aliases":["sparkles"]},{"emoji":"🎈","aliases":["balloon"]},{"emoji":"🎉","aliases":["tada"]},{"emoji":"🎊","aliases":["confetti_ball"]},{"emoji":"🎋","aliases":["tanabata_tree"]},{"emoji":"🎍","aliases":["bamboo"]},{"emoji":"🎎","aliases":["dolls"]},{"emoji":"🎏","aliases":["flags"]},{"emoji":"🎐","aliases":["wind_chime"]},{"emoji":"🎑","aliases":["rice_scene"]},{"emoji":"🧧","aliases":["red_envelope"]},{"emoji":"🎀","aliases":["ribbon"]},{"emoji":"🎁","aliases":["gift"]},{"emoji":"🎗️","aliases":["reminder_ribbon"]},{"emoji":"🎟️","aliases":["tickets"]},{"emoji":"🎫","aliases":["ticket"]},{"emoji":"🎖️","aliases":["medal_military"]},{"emoji":"🏆","aliases":["trophy"]},{"emoji":"🏅","aliases":["medal_sports"]},{"emoji":"🥇","aliases":["1st_place_medal"]},{"emoji":"🥈","aliases":["2nd_place_medal"]},{"emoji":"🥉","aliases":["3rd_place_medal"]},{"emoji":"⚽","aliases":["soccer"]},{"emoji":"⚾","aliases":["baseball"]},{"emoji":"🥎","aliases":["softball"]},{"emoji":"🏀","aliases":["basketball"]},{"emoji":"🏐","aliases":["volleyball"]},{"emoji":"🏈","aliases":["football"]},{"emoji":"🏉","aliases":["rugby_football"]},{"emoji":"🎾","aliases":["tennis"]},{"emoji":"🥏","aliases":["flying_disc"]},{"emoji":"🎳","aliases":["bowling"]},{"emoji":"🏏","aliases":["cricket_game"]},{"emoji":"🏑","aliases":["field_hockey"]},{"emoji":"🏒","aliases":["ice_hockey"]},{"emoji":"🥍","aliases":["lacrosse"]},{"emoji":"🏓","aliases":["ping_pong"]},{"emoji":"🏸","aliases":["badminton"]},{"emoji":"🥊","aliases":["boxing_glove"]},{"emoji":"🥋","aliases":["martial_arts_uniform"]},{"emoji":"🥅","aliases":["goal_net"]},{"emoji":"⛳","aliases":["golf"]},{"emoji":"⛸️","aliases":["ice_skate"]},{"emoji":"🎣","aliases":["fishing_pole_and_fish"]},{"emoji":"🤿","aliases":["diving_mask"]},{"emoji":"🎽","aliases":["running_shirt_with_sash"]},{"emoji":"🎿","aliases":["ski"]},{"emoji":"🛷","aliases":["sled"]},{"emoji":"🥌","aliases":["curling_stone"]},{"emoji":"🎯","aliases":["dart"]},{"emoji":"🪀","aliases":["yo_yo"]},{"emoji":"🪁","aliases":["kite"]},{"emoji":"🎱","aliases":["8ball"]},{"emoji":"🔮","aliases":["crystal_ball"]},{"emoji":"🪄","aliases":["magic_wand"]},{"emoji":"🧿","aliases":["nazar_amulet"]},{"emoji":"🎮","aliases":["video_game"]},{"emoji":"🕹️","aliases":["joystick"]},{"emoji":"🎰","aliases":["slot_machine"]},{"emoji":"🎲","aliases":["game_die"]},{"emoji":"🧩","aliases":["jigsaw"]},{"emoji":"🧸","aliases":["teddy_bear"]},{"emoji":"🪅","aliases":["pinata"]},{"emoji":"🪆","aliases":["nesting_dolls"]},{"emoji":"♠️","aliases":["spades"]},{"emoji":"♥️","aliases":["hearts"]},{"emoji":"♦️","aliases":["diamonds"]},{"emoji":"♣️","aliases":["clubs"]},{"emoji":"♟️","aliases":["chess_pawn"]},{"emoji":"🃏","aliases":["black_joker"]},{"emoji":"🀄","aliases":["mahjong"]},{"emoji":"🎴","aliases":["flower_playing_cards"]},{"emoji":"🎭","aliases":["performing_arts"]},{"emoji":"🖼️","aliases":["framed_picture"]},{"emoji":"🎨","aliases":["art"]},{"emoji":"🧵","aliases":["thread"]},{"emoji":"🪡","aliases":["sewing_needle"]},{"emoji":"🧶","aliases":["yarn"]},{"emoji":"🪢","aliases":["knot"]},{"emoji":"👓","aliases":["eyeglasses"]},{"emoji":"🕶️","aliases":["dark_sunglasses"]},{"emoji":"🥽","aliases":["goggles"]},{"emoji":"🥼","aliases":["lab_coat"]},{"emoji":"🦺","aliases":["safety_vest"]},{"emoji":"👔","aliases":["necktie"]},{"emoji":"👕","aliases":["shirt","tshirt"]},{"emoji":"👖","aliases":["jeans"]},{"emoji":"🧣","aliases":["scarf"]},{"emoji":"🧤","aliases":["gloves"]},{"emoji":"🧥","aliases":["coat"]},{"emoji":"🧦","aliases":["socks"]},{"emoji":"👗","aliases":["dress"]},{"emoji":"👘","aliases":["kimono"]},{"emoji":"🥻","aliases":["sari"]},{"emoji":"🩱","aliases":["one_piece_swimsuit"]},{"emoji":"🩲","aliases":["swim_brief"]},{"emoji":"🩳","aliases":["shorts"]},{"emoji":"👙","aliases":["bikini"]},{"emoji":"👚","aliases":["womans_clothes"]},{"emoji":"👛","aliases":["purse"]},{"emoji":"👜","aliases":["handbag"]},{"emoji":"👝","aliases":["pouch"]},{"emoji":"🛍️","aliases":["shopping"]},{"emoji":"🎒","aliases":["school_satchel"]},{"emoji":"🩴","aliases":["thong_sandal"]},{"emoji":"👞","aliases":["mans_shoe","shoe"]},{"emoji":"👟","aliases":["athletic_shoe"]},{"emoji":"🥾","aliases":["hiking_boot"]},{"emoji":"🥿","aliases":["flat_shoe"]},{"emoji":"👠","aliases":["high_heel"]},{"emoji":"👡","aliases":["sandal"]},{"emoji":"🩰","aliases":["ballet_shoes"]},{"emoji":"👢","aliases":["boot"]},{"emoji":"👑","aliases":["crown"]},{"emoji":"👒","aliases":["womans_hat"]},{"emoji":"🎩","aliases":["tophat"]},{"emoji":"🎓","aliases":["mortar_board"]},{"emoji":"🧢","aliases":["billed_cap"]},{"emoji":"🪖","aliases":["military_helmet"]},{"emoji":"⛑️","aliases":["rescue_worker_helmet"]},{"emoji":"📿","aliases":["prayer_beads"]},{"emoji":"💄","aliases":["lipstick"]},{"emoji":"💍","aliases":["ring"]},{"emoji":"💎","aliases":["gem"]},{"emoji":"🔇","aliases":["mute"]},{"emoji":"🔈","aliases":["speaker"]},{"emoji":"🔉","aliases":["sound"]},{"emoji":"🔊","aliases":["loud_sound"]},{"emoji":"📢","aliases":["loudspeaker"]},{"emoji":"📣","aliases":["mega"]},{"emoji":"📯","aliases":["postal_horn"]},{"emoji":"🔔","aliases":["bell"]},{"emoji":"🔕","aliases":["no_bell"]},{"emoji":"🎼","aliases":["musical_score"]},{"emoji":"🎵","aliases":["musical_note"]},{"emoji":"🎶","aliases":["notes"]},{"emoji":"🎙️","aliases":["studio_microphone"]},{"emoji":"🎚️","aliases":["level_slider"]},{"emoji":"🎛️","aliases":["control_knobs"]},{"emoji":"🎤","aliases":["microphone"]},{"emoji":"🎧","aliases":["headphones"]},{"emoji":"📻","aliases":["radio"]},{"emoji":"🎷","aliases":["saxophone"]},{"emoji":"🪗","aliases":["accordion"]},{"emoji":"🎸","aliases":["guitar"]},{"emoji":"🎹","aliases":["musical_keyboard"]},{"emoji":"🎺","aliases":["trumpet"]},{"emoji":"🎻","aliases":["violin"]},{"emoji":"🪕","aliases":["banjo"]},{"emoji":"🥁","aliases":["drum"]},{"emoji":"🪘","aliases":["long_drum"]},{"emoji":"📱","aliases":["iphone"]},{"emoji":"📲","aliases":["calling"]},{"emoji":"☎️","aliases":["phone","telephone"]},{"emoji":"📞","aliases":["telephone_receiver"]},{"emoji":"📟","aliases":["pager"]},{"emoji":"📠","aliases":["fax"]},{"emoji":"🔋","aliases":["battery"]},{"emoji":"🔌","aliases":["electric_plug"]},{"emoji":"💻","aliases":["computer"]},{"emoji":"🖥️","aliases":["desktop_computer"]},{"emoji":"🖨️","aliases":["printer"]},{"emoji":"⌨️","aliases":["keyboard"]},{"emoji":"🖱️","aliases":["computer_mouse"]},{"emoji":"🖲️","aliases":["trackball"]},{"emoji":"💽","aliases":["minidisc"]},{"emoji":"💾","aliases":["floppy_disk"]},{"emoji":"💿","aliases":["cd"]},{"emoji":"📀","aliases":["dvd"]},{"emoji":"🧮","aliases":["abacus"]},{"emoji":"🎥","aliases":["movie_camera"]},{"emoji":"🎞️","aliases":["film_strip"]},{"emoji":"📽️","aliases":["film_projector"]},{"emoji":"🎬","aliases":["clapper"]},{"emoji":"📺","aliases":["tv"]},{"emoji":"📷","aliases":["camera"]},{"emoji":"📸","aliases":["camera_flash"]},{"emoji":"📹","aliases":["video_camera"]},{"emoji":"📼","aliases":["vhs"]},{"emoji":"🔍","aliases":["mag"]},{"emoji":"🔎","aliases":["mag_right"]},{"emoji":"🕯️","aliases":["candle"]},{"emoji":"💡","aliases":["bulb"]},{"emoji":"🔦","aliases":["flashlight"]},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"]},{"emoji":"🪔","aliases":["diya_lamp"]},{"emoji":"📔","aliases":["notebook_with_decorative_cover"]},{"emoji":"📕","aliases":["closed_book"]},{"emoji":"📖","aliases":["book","open_book"]},{"emoji":"📗","aliases":["green_book"]},{"emoji":"📘","aliases":["blue_book"]},{"emoji":"📙","aliases":["orange_book"]},{"emoji":"📚","aliases":["books"]},{"emoji":"📓","aliases":["notebook"]},{"emoji":"📒","aliases":["ledger"]},{"emoji":"📃","aliases":["page_with_curl"]},{"emoji":"📜","aliases":["scroll"]},{"emoji":"📄","aliases":["page_facing_up"]},{"emoji":"📰","aliases":["newspaper"]},{"emoji":"🗞️","aliases":["newspaper_roll"]},{"emoji":"📑","aliases":["bookmark_tabs"]},{"emoji":"🔖","aliases":["bookmark"]},{"emoji":"🏷️","aliases":["label"]},{"emoji":"💰","aliases":["moneybag"]},{"emoji":"🪙","aliases":["coin"]},{"emoji":"💴","aliases":["yen"]},{"emoji":"💵","aliases":["dollar"]},{"emoji":"💶","aliases":["euro"]},{"emoji":"💷","aliases":["pound"]},{"emoji":"💸","aliases":["money_with_wings"]},{"emoji":"💳","aliases":["credit_card"]},{"emoji":"🧾","aliases":["receipt"]},{"emoji":"💹","aliases":["chart"]},{"emoji":"✉️","aliases":["envelope"]},{"emoji":"📧","aliases":["email","e-mail"]},{"emoji":"📨","aliases":["incoming_envelope"]},{"emoji":"📩","aliases":["envelope_with_arrow"]},{"emoji":"📤","aliases":["outbox_tray"]},{"emoji":"📥","aliases":["inbox_tray"]},{"emoji":"📦","aliases":["package"]},{"emoji":"📫","aliases":["mailbox"]},{"emoji":"📪","aliases":["mailbox_closed"]},{"emoji":"📬","aliases":["mailbox_with_mail"]},{"emoji":"📭","aliases":["mailbox_with_no_mail"]},{"emoji":"📮","aliases":["postbox"]},{"emoji":"🗳️","aliases":["ballot_box"]},{"emoji":"✏️","aliases":["pencil2"]},{"emoji":"✒️","aliases":["black_nib"]},{"emoji":"🖋️","aliases":["fountain_pen"]},{"emoji":"🖊️","aliases":["pen"]},{"emoji":"🖌️","aliases":["paintbrush"]},{"emoji":"🖍️","aliases":["crayon"]},{"emoji":"📝","aliases":["memo","pencil"]},{"emoji":"💼","aliases":["briefcase"]},{"emoji":"📁","aliases":["file_folder"]},{"emoji":"📂","aliases":["open_file_folder"]},{"emoji":"🗂️","aliases":["card_index_dividers"]},{"emoji":"📅","aliases":["date"]},{"emoji":"📆","aliases":["calendar"]},{"emoji":"🗒️","aliases":["spiral_notepad"]},{"emoji":"🗓️","aliases":["spiral_calendar"]},{"emoji":"📇","aliases":["card_index"]},{"emoji":"📈","aliases":["chart_with_upwards_trend"]},{"emoji":"📉","aliases":["chart_with_downwards_trend"]},{"emoji":"📊","aliases":["bar_chart"]},{"emoji":"📋","aliases":["clipboard"]},{"emoji":"📌","aliases":["pushpin"]},{"emoji":"📍","aliases":["round_pushpin"]},{"emoji":"📎","aliases":["paperclip"]},{"emoji":"🖇️","aliases":["paperclips"]},{"emoji":"📏","aliases":["straight_ruler"]},{"emoji":"📐","aliases":["triangular_ruler"]},{"emoji":"✂️","aliases":["scissors"]},{"emoji":"🗃️","aliases":["card_file_box"]},{"emoji":"🗄️","aliases":["file_cabinet"]},{"emoji":"🗑️","aliases":["wastebasket"]},{"emoji":"🔒","aliases":["lock"]},{"emoji":"🔓","aliases":["unlock"]},{"emoji":"🔏","aliases":["lock_with_ink_pen"]},{"emoji":"🔐","aliases":["closed_lock_with_key"]},{"emoji":"🔑","aliases":["key"]},{"emoji":"🗝️","aliases":["old_key"]},{"emoji":"🔨","aliases":["hammer"]},{"emoji":"🪓","aliases":["axe"]},{"emoji":"⛏️","aliases":["pick"]},{"emoji":"⚒️","aliases":["hammer_and_pick"]},{"emoji":"🛠️","aliases":["hammer_and_wrench"]},{"emoji":"🗡️","aliases":["dagger"]},{"emoji":"⚔️","aliases":["crossed_swords"]},{"emoji":"🔫","aliases":["gun"]},{"emoji":"🪃","aliases":["boomerang"]},{"emoji":"🏹","aliases":["bow_and_arrow"]},{"emoji":"🛡️","aliases":["shield"]},{"emoji":"🪚","aliases":["carpentry_saw"]},{"emoji":"🔧","aliases":["wrench"]},{"emoji":"🪛","aliases":["screwdriver"]},{"emoji":"🔩","aliases":["nut_and_bolt"]},{"emoji":"⚙️","aliases":["gear"]},{"emoji":"🗜️","aliases":["clamp"]},{"emoji":"⚖️","aliases":["balance_scale"]},{"emoji":"🦯","aliases":["probing_cane"]},{"emoji":"🔗","aliases":["link"]},{"emoji":"⛓️","aliases":["chains"]},{"emoji":"🪝","aliases":["hook"]},{"emoji":"🧰","aliases":["toolbox"]},{"emoji":"🧲","aliases":["magnet"]},{"emoji":"🪜","aliases":["ladder"]},{"emoji":"⚗️","aliases":["alembic"]},{"emoji":"🧪","aliases":["test_tube"]},{"emoji":"🧫","aliases":["petri_dish"]},{"emoji":"🧬","aliases":["dna"]},{"emoji":"🔬","aliases":["microscope"]},{"emoji":"🔭","aliases":["telescope"]},{"emoji":"📡","aliases":["satellite"]},{"emoji":"💉","aliases":["syringe"]},{"emoji":"🩸","aliases":["drop_of_blood"]},{"emoji":"💊","aliases":["pill"]},{"emoji":"🩹","aliases":["adhesive_bandage"]},{"emoji":"🩺","aliases":["stethoscope"]},{"emoji":"🚪","aliases":["door"]},{"emoji":"🛗","aliases":["elevator"]},{"emoji":"🪞","aliases":["mirror"]},{"emoji":"🪟","aliases":["window"]},{"emoji":"🛏️","aliases":["bed"]},{"emoji":"🛋️","aliases":["couch_and_lamp"]},{"emoji":"🪑","aliases":["chair"]},{"emoji":"🚽","aliases":["toilet"]},{"emoji":"🪠","aliases":["plunger"]},{"emoji":"🚿","aliases":["shower"]},{"emoji":"🛁","aliases":["bathtub"]},{"emoji":"🪤","aliases":["mouse_trap"]},{"emoji":"🪒","aliases":["razor"]},{"emoji":"🧴","aliases":["lotion_bottle"]},{"emoji":"🧷","aliases":["safety_pin"]},{"emoji":"🧹","aliases":["broom"]},{"emoji":"🧺","aliases":["basket"]},{"emoji":"🧻","aliases":["roll_of_paper"]},{"emoji":"🪣","aliases":["bucket"]},{"emoji":"🧼","aliases":["soap"]},{"emoji":"🪥","aliases":["toothbrush"]},{"emoji":"🧽","aliases":["sponge"]},{"emoji":"🧯","aliases":["fire_extinguisher"]},{"emoji":"🛒","aliases":["shopping_cart"]},{"emoji":"🚬","aliases":["smoking"]},{"emoji":"⚰️","aliases":["coffin"]},{"emoji":"🪦","aliases":["headstone"]},{"emoji":"⚱️","aliases":["funeral_urn"]},{"emoji":"🗿","aliases":["moyai"]},{"emoji":"🪧","aliases":["placard"]},{"emoji":"🏧","aliases":["atm"]},{"emoji":"🚮","aliases":["put_litter_in_its_place"]},{"emoji":"🚰","aliases":["potable_water"]},{"emoji":"♿","aliases":["wheelchair"]},{"emoji":"🚹","aliases":["mens"]},{"emoji":"🚺","aliases":["womens"]},{"emoji":"🚻","aliases":["restroom"]},{"emoji":"🚼","aliases":["baby_symbol"]},{"emoji":"🚾","aliases":["wc"]},{"emoji":"🛂","aliases":["passport_control"]},{"emoji":"🛃","aliases":["customs"]},{"emoji":"🛄","aliases":["baggage_claim"]},{"emoji":"🛅","aliases":["left_luggage"]},{"emoji":"⚠️","aliases":["warning"]},{"emoji":"🚸","aliases":["children_crossing"]},{"emoji":"⛔","aliases":["no_entry"]},{"emoji":"🚫","aliases":["no_entry_sign"]},{"emoji":"🚳","aliases":["no_bicycles"]},{"emoji":"🚭","aliases":["no_smoking"]},{"emoji":"🚯","aliases":["do_not_litter"]},{"emoji":"🚱","aliases":["non-potable_water"]},{"emoji":"🚷","aliases":["no_pedestrians"]},{"emoji":"📵","aliases":["no_mobile_phones"]},{"emoji":"🔞","aliases":["underage"]},{"emoji":"☢️","aliases":["radioactive"]},{"emoji":"☣️","aliases":["biohazard"]},{"emoji":"⬆️","aliases":["arrow_up"]},{"emoji":"↗️","aliases":["arrow_upper_right"]},{"emoji":"➡️","aliases":["arrow_right"]},{"emoji":"↘️","aliases":["arrow_lower_right"]},{"emoji":"⬇️","aliases":["arrow_down"]},{"emoji":"↙️","aliases":["arrow_lower_left"]},{"emoji":"⬅️","aliases":["arrow_left"]},{"emoji":"↖️","aliases":["arrow_upper_left"]},{"emoji":"↕️","aliases":["arrow_up_down"]},{"emoji":"↔️","aliases":["left_right_arrow"]},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"]},{"emoji":"↪️","aliases":["arrow_right_hook"]},{"emoji":"⤴️","aliases":["arrow_heading_up"]},{"emoji":"⤵️","aliases":["arrow_heading_down"]},{"emoji":"🔃","aliases":["arrows_clockwise"]},{"emoji":"🔄","aliases":["arrows_counterclockwise"]},{"emoji":"🔙","aliases":["back"]},{"emoji":"🔚","aliases":["end"]},{"emoji":"🔛","aliases":["on"]},{"emoji":"🔜","aliases":["soon"]},{"emoji":"🔝","aliases":["top"]},{"emoji":"🛐","aliases":["place_of_worship"]},{"emoji":"⚛️","aliases":["atom_symbol"]},{"emoji":"🕉️","aliases":["om"]},{"emoji":"✡️","aliases":["star_of_david"]},{"emoji":"☸️","aliases":["wheel_of_dharma"]},{"emoji":"☯️","aliases":["yin_yang"]},{"emoji":"✝️","aliases":["latin_cross"]},{"emoji":"☦️","aliases":["orthodox_cross"]},{"emoji":"☪️","aliases":["star_and_crescent"]},{"emoji":"☮️","aliases":["peace_symbol"]},{"emoji":"🕎","aliases":["menorah"]},{"emoji":"🔯","aliases":["six_pointed_star"]},{"emoji":"♈","aliases":["aries"]},{"emoji":"♉","aliases":["taurus"]},{"emoji":"♊","aliases":["gemini"]},{"emoji":"♋","aliases":["cancer"]},{"emoji":"♌","aliases":["leo"]},{"emoji":"♍","aliases":["virgo"]},{"emoji":"♎","aliases":["libra"]},{"emoji":"♏","aliases":["scorpius"]},{"emoji":"♐","aliases":["sagittarius"]},{"emoji":"♑","aliases":["capricorn"]},{"emoji":"♒","aliases":["aquarius"]},{"emoji":"♓","aliases":["pisces"]},{"emoji":"⛎","aliases":["ophiuchus"]},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"]},{"emoji":"🔁","aliases":["repeat"]},{"emoji":"🔂","aliases":["repeat_one"]},{"emoji":"▶️","aliases":["arrow_forward"]},{"emoji":"⏩","aliases":["fast_forward"]},{"emoji":"⏭️","aliases":["next_track_button"]},{"emoji":"⏯️","aliases":["play_or_pause_button"]},{"emoji":"◀️","aliases":["arrow_backward"]},{"emoji":"⏪","aliases":["rewind"]},{"emoji":"⏮️","aliases":["previous_track_button"]},{"emoji":"🔼","aliases":["arrow_up_small"]},{"emoji":"⏫","aliases":["arrow_double_up"]},{"emoji":"🔽","aliases":["arrow_down_small"]},{"emoji":"⏬","aliases":["arrow_double_down"]},{"emoji":"⏸️","aliases":["pause_button"]},{"emoji":"⏹️","aliases":["stop_button"]},{"emoji":"⏺️","aliases":["record_button"]},{"emoji":"⏏️","aliases":["eject_button"]},{"emoji":"🎦","aliases":["cinema"]},{"emoji":"🔅","aliases":["low_brightness"]},{"emoji":"🔆","aliases":["high_brightness"]},{"emoji":"📶","aliases":["signal_strength"]},{"emoji":"📳","aliases":["vibration_mode"]},{"emoji":"📴","aliases":["mobile_phone_off"]},{"emoji":"♀️","aliases":["female_sign"]},{"emoji":"♂️","aliases":["male_sign"]},{"emoji":"⚧️","aliases":["transgender_symbol"]},{"emoji":"✖️","aliases":["heavy_multiplication_x"]},{"emoji":"➕","aliases":["heavy_plus_sign"]},{"emoji":"➖","aliases":["heavy_minus_sign"]},{"emoji":"➗","aliases":["heavy_division_sign"]},{"emoji":"♾️","aliases":["infinity"]},{"emoji":"‼️","aliases":["bangbang"]},{"emoji":"⁉️","aliases":["interrobang"]},{"emoji":"❓","aliases":["question"]},{"emoji":"❔","aliases":["grey_question"]},{"emoji":"❕","aliases":["grey_exclamation"]},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"]},{"emoji":"〰️","aliases":["wavy_dash"]},{"emoji":"💱","aliases":["currency_exchange"]},{"emoji":"💲","aliases":["heavy_dollar_sign"]},{"emoji":"⚕️","aliases":["medical_symbol"]},{"emoji":"♻️","aliases":["recycle"]},{"emoji":"⚜️","aliases":["fleur_de_lis"]},{"emoji":"🔱","aliases":["trident"]},{"emoji":"📛","aliases":["name_badge"]},{"emoji":"🔰","aliases":["beginner"]},{"emoji":"⭕","aliases":["o"]},{"emoji":"✅","aliases":["white_check_mark"]},{"emoji":"☑️","aliases":["ballot_box_with_check"]},{"emoji":"✔️","aliases":["heavy_check_mark"]},{"emoji":"❌","aliases":["x"]},{"emoji":"❎","aliases":["negative_squared_cross_mark"]},{"emoji":"➰","aliases":["curly_loop"]},{"emoji":"➿","aliases":["loop"]},{"emoji":"〽️","aliases":["part_alternation_mark"]},{"emoji":"✳️","aliases":["eight_spoked_asterisk"]},{"emoji":"✴️","aliases":["eight_pointed_black_star"]},{"emoji":"❇️","aliases":["sparkle"]},{"emoji":"©️","aliases":["copyright"]},{"emoji":"®️","aliases":["registered"]},{"emoji":"™️","aliases":["tm"]},{"emoji":"#️⃣","aliases":["hash"]},{"emoji":"*️⃣","aliases":["asterisk"]},{"emoji":"0️⃣","aliases":["zero"]},{"emoji":"1️⃣","aliases":["one"]},{"emoji":"2️⃣","aliases":["two"]},{"emoji":"3️⃣","aliases":["three"]},{"emoji":"4️⃣","aliases":["four"]},{"emoji":"5️⃣","aliases":["five"]},{"emoji":"6️⃣","aliases":["six"]},{"emoji":"7️⃣","aliases":["seven"]},{"emoji":"8️⃣","aliases":["eight"]},{"emoji":"9️⃣","aliases":["nine"]},{"emoji":"🔟","aliases":["keycap_ten"]},{"emoji":"🔠","aliases":["capital_abcd"]},{"emoji":"🔡","aliases":["abcd"]},{"emoji":"🔢","aliases":["1234"]},{"emoji":"🔣","aliases":["symbols"]},{"emoji":"🔤","aliases":["abc"]},{"emoji":"🅰️","aliases":["a"]},{"emoji":"🆎","aliases":["ab"]},{"emoji":"🅱️","aliases":["b"]},{"emoji":"🆑","aliases":["cl"]},{"emoji":"🆒","aliases":["cool"]},{"emoji":"🆓","aliases":["free"]},{"emoji":"ℹ️","aliases":["information_source"]},{"emoji":"🆔","aliases":["id"]},{"emoji":"Ⓜ️","aliases":["m"]},{"emoji":"🆕","aliases":["new"]},{"emoji":"🆖","aliases":["ng"]},{"emoji":"🅾️","aliases":["o2"]},{"emoji":"🆗","aliases":["ok"]},{"emoji":"🅿️","aliases":["parking"]},{"emoji":"🆘","aliases":["sos"]},{"emoji":"🆙","aliases":["up"]},{"emoji":"🆚","aliases":["vs"]},{"emoji":"🈁","aliases":["koko"]},{"emoji":"🈂️","aliases":["sa"]},{"emoji":"🈷️","aliases":["u6708"]},{"emoji":"🈶","aliases":["u6709"]},{"emoji":"🈯","aliases":["u6307"]},{"emoji":"🉐","aliases":["ideograph_advantage"]},{"emoji":"🈹","aliases":["u5272"]},{"emoji":"🈚","aliases":["u7121"]},{"emoji":"🈲","aliases":["u7981"]},{"emoji":"🉑","aliases":["accept"]},{"emoji":"🈸","aliases":["u7533"]},{"emoji":"🈴","aliases":["u5408"]},{"emoji":"🈳","aliases":["u7a7a"]},{"emoji":"㊗️","aliases":["congratulations"]},{"emoji":"㊙️","aliases":["secret"]},{"emoji":"🈺","aliases":["u55b6"]},{"emoji":"🈵","aliases":["u6e80"]},{"emoji":"🔴","aliases":["red_circle"]},{"emoji":"🟠","aliases":["orange_circle"]},{"emoji":"🟡","aliases":["yellow_circle"]},{"emoji":"🟢","aliases":["green_circle"]},{"emoji":"🔵","aliases":["large_blue_circle"]},{"emoji":"🟣","aliases":["purple_circle"]},{"emoji":"🟤","aliases":["brown_circle"]},{"emoji":"⚫","aliases":["black_circle"]},{"emoji":"⚪","aliases":["white_circle"]},{"emoji":"🟥","aliases":["red_square"]},{"emoji":"🟧","aliases":["orange_square"]},{"emoji":"🟨","aliases":["yellow_square"]},{"emoji":"🟩","aliases":["green_square"]},{"emoji":"🟦","aliases":["blue_square"]},{"emoji":"🟪","aliases":["purple_square"]},{"emoji":"🟫","aliases":["brown_square"]},{"emoji":"⬛","aliases":["black_large_square"]},{"emoji":"⬜","aliases":["white_large_square"]},{"emoji":"◼️","aliases":["black_medium_square"]},{"emoji":"◻️","aliases":["white_medium_square"]},{"emoji":"◾","aliases":["black_medium_small_square"]},{"emoji":"◽","aliases":["white_medium_small_square"]},{"emoji":"▪️","aliases":["black_small_square"]},{"emoji":"▫️","aliases":["white_small_square"]},{"emoji":"🔶","aliases":["large_orange_diamond"]},{"emoji":"🔷","aliases":["large_blue_diamond"]},{"emoji":"🔸","aliases":["small_orange_diamond"]},{"emoji":"🔹","aliases":["small_blue_diamond"]},{"emoji":"🔺","aliases":["small_red_triangle"]},{"emoji":"🔻","aliases":["small_red_triangle_down"]},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"]},{"emoji":"🔘","aliases":["radio_button"]},{"emoji":"🔳","aliases":["white_square_button"]},{"emoji":"🔲","aliases":["black_square_button"]},{"emoji":"🏁","aliases":["checkered_flag"]},{"emoji":"🚩","aliases":["triangular_flag_on_post"]},{"emoji":"🎌","aliases":["crossed_flags"]},{"emoji":"🏴","aliases":["black_flag"]},{"emoji":"🏳️","aliases":["white_flag"]},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"]},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"]},{"emoji":"🏴‍☠️","aliases":["pirate_flag"]},{"emoji":"🇦🇨","aliases":["ascension_island"]},{"emoji":"🇦🇩","aliases":["andorra"]},{"emoji":"🇦🇪","aliases":["united_arab_emirates"]},{"emoji":"🇦🇫","aliases":["afghanistan"]},{"emoji":"🇦🇬","aliases":["antigua_barbuda"]},{"emoji":"🇦🇮","aliases":["anguilla"]},{"emoji":"🇦🇱","aliases":["albania"]},{"emoji":"🇦🇲","aliases":["armenia"]},{"emoji":"🇦🇴","aliases":["angola"]},{"emoji":"🇦🇶","aliases":["antarctica"]},{"emoji":"🇦🇷","aliases":["argentina"]},{"emoji":"🇦🇸","aliases":["american_samoa"]},{"emoji":"🇦🇹","aliases":["austria"]},{"emoji":"🇦🇺","aliases":["australia"]},{"emoji":"🇦🇼","aliases":["aruba"]},{"emoji":"🇦🇽","aliases":["aland_islands"]},{"emoji":"🇦🇿","aliases":["azerbaijan"]},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"]},{"emoji":"🇧🇧","aliases":["barbados"]},{"emoji":"🇧🇩","aliases":["bangladesh"]},{"emoji":"🇧🇪","aliases":["belgium"]},{"emoji":"🇧🇫","aliases":["burkina_faso"]},{"emoji":"🇧🇬","aliases":["bulgaria"]},{"emoji":"🇧🇭","aliases":["bahrain"]},{"emoji":"🇧🇮","aliases":["burundi"]},{"emoji":"🇧🇯","aliases":["benin"]},{"emoji":"🇧🇱","aliases":["st_barthelemy"]},{"emoji":"🇧🇲","aliases":["bermuda"]},{"emoji":"🇧🇳","aliases":["brunei"]},{"emoji":"🇧🇴","aliases":["bolivia"]},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"]},{"emoji":"🇧🇷","aliases":["brazil"]},{"emoji":"🇧🇸","aliases":["bahamas"]},{"emoji":"🇧🇹","aliases":["bhutan"]},{"emoji":"🇧🇻","aliases":["bouvet_island"]},{"emoji":"🇧🇼","aliases":["botswana"]},{"emoji":"🇧🇾","aliases":["belarus"]},{"emoji":"🇧🇿","aliases":["belize"]},{"emoji":"🇨🇦","aliases":["canada"]},{"emoji":"🇨🇨","aliases":["cocos_islands"]},{"emoji":"🇨🇩","aliases":["congo_kinshasa"]},{"emoji":"🇨🇫","aliases":["central_african_republic"]},{"emoji":"🇨🇬","aliases":["congo_brazzaville"]},{"emoji":"🇨🇭","aliases":["switzerland"]},{"emoji":"🇨🇮","aliases":["cote_divoire"]},{"emoji":"🇨🇰","aliases":["cook_islands"]},{"emoji":"🇨🇱","aliases":["chile"]},{"emoji":"🇨🇲","aliases":["cameroon"]},{"emoji":"🇨🇳","aliases":["cn"]},{"emoji":"🇨🇴","aliases":["colombia"]},{"emoji":"🇨🇵","aliases":["clipperton_island"]},{"emoji":"🇨🇷","aliases":["costa_rica"]},{"emoji":"🇨🇺","aliases":["cuba"]},{"emoji":"🇨🇻","aliases":["cape_verde"]},{"emoji":"🇨🇼","aliases":["curacao"]},{"emoji":"🇨🇽","aliases":["christmas_island"]},{"emoji":"🇨🇾","aliases":["cyprus"]},{"emoji":"🇨🇿","aliases":["czech_republic"]},{"emoji":"🇩🇪","aliases":["de"]},{"emoji":"🇩🇬","aliases":["diego_garcia"]},{"emoji":"🇩🇯","aliases":["djibouti"]},{"emoji":"🇩🇰","aliases":["denmark"]},{"emoji":"🇩🇲","aliases":["dominica"]},{"emoji":"🇩🇴","aliases":["dominican_republic"]},{"emoji":"🇩🇿","aliases":["algeria"]},{"emoji":"🇪🇦","aliases":["ceuta_melilla"]},{"emoji":"🇪🇨","aliases":["ecuador"]},{"emoji":"🇪🇪","aliases":["estonia"]},{"emoji":"🇪🇬","aliases":["egypt"]},{"emoji":"🇪🇭","aliases":["western_sahara"]},{"emoji":"🇪🇷","aliases":["eritrea"]},{"emoji":"🇪🇸","aliases":["es"]},{"emoji":"🇪🇹","aliases":["ethiopia"]},{"emoji":"🇪🇺","aliases":["eu","european_union"]},{"emoji":"🇫🇮","aliases":["finland"]},{"emoji":"🇫🇯","aliases":["fiji"]},{"emoji":"🇫🇰","aliases":["falkland_islands"]},{"emoji":"🇫🇲","aliases":["micronesia"]},{"emoji":"🇫🇴","aliases":["faroe_islands"]},{"emoji":"🇫🇷","aliases":["fr"]},{"emoji":"🇬🇦","aliases":["gabon"]},{"emoji":"🇬🇧","aliases":["gb","uk"]},{"emoji":"🇬🇩","aliases":["grenada"]},{"emoji":"🇬🇪","aliases":["georgia"]},{"emoji":"🇬🇫","aliases":["french_guiana"]},{"emoji":"🇬🇬","aliases":["guernsey"]},{"emoji":"🇬🇭","aliases":["ghana"]},{"emoji":"🇬🇮","aliases":["gibraltar"]},{"emoji":"🇬🇱","aliases":["greenland"]},{"emoji":"🇬🇲","aliases":["gambia"]},{"emoji":"🇬🇳","aliases":["guinea"]},{"emoji":"🇬🇵","aliases":["guadeloupe"]},{"emoji":"🇬🇶","aliases":["equatorial_guinea"]},{"emoji":"🇬🇷","aliases":["greece"]},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"]},{"emoji":"🇬🇹","aliases":["guatemala"]},{"emoji":"🇬🇺","aliases":["guam"]},{"emoji":"🇬🇼","aliases":["guinea_bissau"]},{"emoji":"🇬🇾","aliases":["guyana"]},{"emoji":"🇭🇰","aliases":["hong_kong"]},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"]},{"emoji":"🇭🇳","aliases":["honduras"]},{"emoji":"🇭🇷","aliases":["croatia"]},{"emoji":"🇭🇹","aliases":["haiti"]},{"emoji":"🇭🇺","aliases":["hungary"]},{"emoji":"🇮🇨","aliases":["canary_islands"]},{"emoji":"🇮🇩","aliases":["indonesia"]},{"emoji":"🇮🇪","aliases":["ireland"]},{"emoji":"🇮🇱","aliases":["israel"]},{"emoji":"🇮🇲","aliases":["isle_of_man"]},{"emoji":"🇮🇳","aliases":["india"]},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"]},{"emoji":"🇮🇶","aliases":["iraq"]},{"emoji":"🇮🇷","aliases":["iran"]},{"emoji":"🇮🇸","aliases":["iceland"]},{"emoji":"🇮🇹","aliases":["it"]},{"emoji":"🇯🇪","aliases":["jersey"]},{"emoji":"🇯🇲","aliases":["jamaica"]},{"emoji":"🇯🇴","aliases":["jordan"]},{"emoji":"🇯🇵","aliases":["jp"]},{"emoji":"🇰🇪","aliases":["kenya"]},{"emoji":"🇰🇬","aliases":["kyrgyzstan"]},{"emoji":"🇰🇭","aliases":["cambodia"]},{"emoji":"🇰🇮","aliases":["kiribati"]},{"emoji":"🇰🇲","aliases":["comoros"]},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"]},{"emoji":"🇰🇵","aliases":["north_korea"]},{"emoji":"🇰🇷","aliases":["kr"]},{"emoji":"🇰🇼","aliases":["kuwait"]},{"emoji":"🇰🇾","aliases":["cayman_islands"]},{"emoji":"🇰🇿","aliases":["kazakhstan"]},{"emoji":"🇱🇦","aliases":["laos"]},{"emoji":"🇱🇧","aliases":["lebanon"]},{"emoji":"🇱🇨","aliases":["st_lucia"]},{"emoji":"🇱🇮","aliases":["liechtenstein"]},{"emoji":"🇱🇰","aliases":["sri_lanka"]},{"emoji":"🇱🇷","aliases":["liberia"]},{"emoji":"🇱🇸","aliases":["lesotho"]},{"emoji":"🇱🇹","aliases":["lithuania"]},{"emoji":"🇱🇺","aliases":["luxembourg"]},{"emoji":"🇱🇻","aliases":["latvia"]},{"emoji":"🇱🇾","aliases":["libya"]},{"emoji":"🇲🇦","aliases":["morocco"]},{"emoji":"🇲🇨","aliases":["monaco"]},{"emoji":"🇲🇩","aliases":["moldova"]},{"emoji":"🇲🇪","aliases":["montenegro"]},{"emoji":"🇲🇫","aliases":["st_martin"]},{"emoji":"🇲🇬","aliases":["madagascar"]},{"emoji":"🇲🇭","aliases":["marshall_islands"]},{"emoji":"🇲🇰","aliases":["macedonia"]},{"emoji":"🇲🇱","aliases":["mali"]},{"emoji":"🇲🇲","aliases":["myanmar"]},{"emoji":"🇲🇳","aliases":["mongolia"]},{"emoji":"🇲🇴","aliases":["macau"]},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"]},{"emoji":"🇲🇶","aliases":["martinique"]},{"emoji":"🇲🇷","aliases":["mauritania"]},{"emoji":"🇲🇸","aliases":["montserrat"]},{"emoji":"🇲🇹","aliases":["malta"]},{"emoji":"🇲🇺","aliases":["mauritius"]},{"emoji":"🇲🇻","aliases":["maldives"]},{"emoji":"🇲🇼","aliases":["malawi"]},{"emoji":"🇲🇽","aliases":["mexico"]},{"emoji":"🇲🇾","aliases":["malaysia"]},{"emoji":"🇲🇿","aliases":["mozambique"]},{"emoji":"🇳🇦","aliases":["namibia"]},{"emoji":"🇳🇨","aliases":["new_caledonia"]},{"emoji":"🇳🇪","aliases":["niger"]},{"emoji":"🇳🇫","aliases":["norfolk_island"]},{"emoji":"🇳🇬","aliases":["nigeria"]},{"emoji":"🇳🇮","aliases":["nicaragua"]},{"emoji":"🇳🇱","aliases":["netherlands"]},{"emoji":"🇳🇴","aliases":["norway"]},{"emoji":"🇳🇵","aliases":["nepal"]},{"emoji":"🇳🇷","aliases":["nauru"]},{"emoji":"🇳🇺","aliases":["niue"]},{"emoji":"🇳🇿","aliases":["new_zealand"]},{"emoji":"🇴🇲","aliases":["oman"]},{"emoji":"🇵🇦","aliases":["panama"]},{"emoji":"🇵🇪","aliases":["peru"]},{"emoji":"🇵🇫","aliases":["french_polynesia"]},{"emoji":"🇵🇬","aliases":["papua_new_guinea"]},{"emoji":"🇵🇭","aliases":["philippines"]},{"emoji":"🇵🇰","aliases":["pakistan"]},{"emoji":"🇵🇱","aliases":["poland"]},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"]},{"emoji":"🇵🇳","aliases":["pitcairn_islands"]},{"emoji":"🇵🇷","aliases":["puerto_rico"]},{"emoji":"🇵🇸","aliases":["palestinian_territories"]},{"emoji":"🇵🇹","aliases":["portugal"]},{"emoji":"🇵🇼","aliases":["palau"]},{"emoji":"🇵🇾","aliases":["paraguay"]},{"emoji":"🇶🇦","aliases":["qatar"]},{"emoji":"🇷🇪","aliases":["reunion"]},{"emoji":"🇷🇴","aliases":["romania"]},{"emoji":"🇷🇸","aliases":["serbia"]},{"emoji":"🇷🇺","aliases":["ru"]},{"emoji":"🇷🇼","aliases":["rwanda"]},{"emoji":"🇸🇦","aliases":["saudi_arabia"]},{"emoji":"🇸🇧","aliases":["solomon_islands"]},{"emoji":"🇸🇨","aliases":["seychelles"]},{"emoji":"🇸🇩","aliases":["sudan"]},{"emoji":"🇸🇪","aliases":["sweden"]},{"emoji":"🇸🇬","aliases":["singapore"]},{"emoji":"🇸🇭","aliases":["st_helena"]},{"emoji":"🇸🇮","aliases":["slovenia"]},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"]},{"emoji":"🇸🇰","aliases":["slovakia"]},{"emoji":"🇸🇱","aliases":["sierra_leone"]},{"emoji":"🇸🇲","aliases":["san_marino"]},{"emoji":"🇸🇳","aliases":["senegal"]},{"emoji":"🇸🇴","aliases":["somalia"]},{"emoji":"🇸🇷","aliases":["suriname"]},{"emoji":"🇸🇸","aliases":["south_sudan"]},{"emoji":"🇸🇹","aliases":["sao_tome_principe"]},{"emoji":"🇸🇻","aliases":["el_salvador"]},{"emoji":"🇸🇽","aliases":["sint_maarten"]},{"emoji":"🇸🇾","aliases":["syria"]},{"emoji":"🇸🇿","aliases":["swaziland"]},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"]},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"]},{"emoji":"🇹🇩","aliases":["chad"]},{"emoji":"🇹🇫","aliases":["french_southern_territories"]},{"emoji":"🇹🇬","aliases":["togo"]},{"emoji":"🇹🇭","aliases":["thailand"]},{"emoji":"🇹🇯","aliases":["tajikistan"]},{"emoji":"🇹🇰","aliases":["tokelau"]},{"emoji":"🇹🇱","aliases":["timor_leste"]},{"emoji":"🇹🇲","aliases":["turkmenistan"]},{"emoji":"🇹🇳","aliases":["tunisia"]},{"emoji":"🇹🇴","aliases":["tonga"]},{"emoji":"🇹🇷","aliases":["tr"]},{"emoji":"🇹🇹","aliases":["trinidad_tobago"]},{"emoji":"🇹🇻","aliases":["tuvalu"]},{"emoji":"🇹🇼","aliases":["taiwan"]},{"emoji":"🇹🇿","aliases":["tanzania"]},{"emoji":"🇺🇦","aliases":["ukraine"]},{"emoji":"🇺🇬","aliases":["uganda"]},{"emoji":"🇺🇲","aliases":["us_outlying_islands"]},{"emoji":"🇺🇳","aliases":["united_nations"]},{"emoji":"🇺🇸","aliases":["us"]},{"emoji":"🇺🇾","aliases":["uruguay"]},{"emoji":"🇺🇿","aliases":["uzbekistan"]},{"emoji":"🇻🇦","aliases":["vatican_city"]},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"]},{"emoji":"🇻🇪","aliases":["venezuela"]},{"emoji":"🇻🇬","aliases":["british_virgin_islands"]},{"emoji":"🇻🇮","aliases":["us_virgin_islands"]},{"emoji":"🇻🇳","aliases":["vietnam"]},{"emoji":"🇻🇺","aliases":["vanuatu"]},{"emoji":"🇼🇫","aliases":["wallis_futuna"]},{"emoji":"🇼🇸","aliases":["samoa"]},{"emoji":"🇽🇰","aliases":["kosovo"]},{"emoji":"🇾🇪","aliases":["yemen"]},{"emoji":"🇾🇹","aliases":["mayotte"]},{"emoji":"🇿🇦","aliases":["south_africa"]},{"emoji":"🇿🇲","aliases":["zambia"]},{"emoji":"🇿🇼","aliases":["zimbabwe"]},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"]},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"]},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"]}] diff --git a/server/mailer_emoji_map.json b/server/mailer_emoji_map.json new file mode 100644 index 00000000..8520c24c --- /dev/null +++ b/server/mailer_emoji_map.json @@ -0,0 +1,1857 @@ +{ + "+1": "👍", + "-1": "👎", + "100": "💯", + "1234": "🔢", + "1st_place_medal": "🥇", + "2nd_place_medal": "🥈", + "3rd_place_medal": "🥉", + "8ball": "🎱", + "a": "🅰️", + "ab": "🆎", + "abacus": "🧮", + "abc": "🔤", + "abcd": "🔡", + "accept": "🉑", + "accordion": "🪗", + "adhesive_bandage": "🩹", + "adult": "🧑", + "aerial_tramway": "🚡", + "afghanistan": "🇦🇫", + "airplane": "✈️", + "aland_islands": "🇦🇽", + "alarm_clock": "⏰", + "albania": "🇦🇱", + "alembic": "⚗️", + "algeria": "🇩🇿", + "alien": "👽", + "ambulance": "🚑", + "american_samoa": "🇦🇸", + "amphora": "🏺", + "anatomical_heart": "🫀", + "anchor": "⚓", + "andorra": "🇦🇩", + "angel": "👼", + "anger": "💢", + "angola": "🇦🇴", + "angry": "😠", + "anguilla": "🇦🇮", + "anguished": "😧", + "ant": "🐜", + "antarctica": "🇦🇶", + "antigua_barbuda": "🇦🇬", + "apple": "🍎", + "aquarius": "♒", + "argentina": "🇦🇷", + "aries": "♈", + "armenia": "🇦🇲", + "arrow_backward": "◀️", + "arrow_double_down": "⏬", + "arrow_double_up": "⏫", + "arrow_down": "⬇️", + "arrow_down_small": "🔽", + "arrow_forward": "▶️", + "arrow_heading_down": "⤵️", + "arrow_heading_up": "⤴️", + "arrow_left": "⬅️", + "arrow_lower_left": "↙️", + "arrow_lower_right": "↘️", + "arrow_right": "➡️", + "arrow_right_hook": "↪️", + "arrow_up": "⬆️", + "arrow_up_down": "↕️", + "arrow_up_small": "🔼", + "arrow_upper_left": "↖️", + "arrow_upper_right": "↗️", + "arrows_clockwise": "🔃", + "arrows_counterclockwise": "🔄", + "art": "🎨", + "articulated_lorry": "🚛", + "artificial_satellite": "🛰️", + "artist": "🧑‍🎨", + "aruba": "🇦🇼", + "ascension_island": "🇦🇨", + "asterisk": "*️⃣", + "astonished": "😲", + "astronaut": "🧑‍🚀", + "athletic_shoe": "👟", + "atm": "🏧", + "atom_symbol": "⚛️", + "australia": "🇦🇺", + "austria": "🇦🇹", + "auto_rickshaw": "🛺", + "avocado": "🥑", + "axe": "🪓", + "azerbaijan": "🇦🇿", + "b": "🅱️", + "baby": "👶", + "baby_bottle": "🍼", + "baby_chick": "🐤", + "baby_symbol": "🚼", + "back": "🔙", + "bacon": "🥓", + "badger": "🦡", + "badminton": "🏸", + "bagel": "🥯", + "baggage_claim": "🛄", + "baguette_bread": "🥖", + "bahamas": "🇧🇸", + "bahrain": "🇧🇭", + "balance_scale": "⚖️", + "bald_man": "👨‍🦲", + "bald_woman": "👩‍🦲", + "ballet_shoes": "🩰", + "balloon": "🎈", + "ballot_box": "🗳️", + "ballot_box_with_check": "☑️", + "bamboo": "🎍", + "banana": "🍌", + "bangbang": "‼️", + "bangladesh": "🇧🇩", + "banjo": "🪕", + "bank": "🏦", + "bar_chart": "📊", + "barbados": "🇧🇧", + "barber": "💈", + "baseball": "⚾", + "basket": "🧺", + "basketball": "🏀", + "basketball_man": "⛹️‍♂️", + "basketball_woman": "⛹️‍♀️", + "bat": "🦇", + "bath": "🛀", + "bathtub": "🛁", + "battery": "🔋", + "beach_umbrella": "🏖️", + "bear": "🐻", + "bearded_person": "🧔", + "beaver": "🦫", + "bed": "🛏️", + "bee": "🐝", + "beer": "🍺", + "beers": "🍻", + "beetle": "🪲", + "beginner": "🔰", + "belarus": "🇧🇾", + "belgium": "🇧🇪", + "belize": "🇧🇿", + "bell": "🔔", + "bell_pepper": "🫑", + "bellhop_bell": "🛎️", + "benin": "🇧🇯", + "bento": "🍱", + "bermuda": "🇧🇲", + "beverage_box": "🧃", + "bhutan": "🇧🇹", + "bicyclist": "🚴", + "bike": "🚲", + "biking_man": "🚴‍♂️", + "biking_woman": "🚴‍♀️", + "bikini": "👙", + "billed_cap": "🧢", + "biohazard": "☣️", + "bird": "🐦", + "birthday": "🎂", + "bison": "🦬", + "black_cat": "🐈‍⬛", + "black_circle": "⚫", + "black_flag": "🏴", + "black_heart": "🖤", + "black_joker": "🃏", + "black_large_square": "⬛", + "black_medium_small_square": "◾", + "black_medium_square": "◼️", + "black_nib": "✒️", + "black_small_square": "▪️", + "black_square_button": "🔲", + "blond_haired_man": "👱‍♂️", + "blond_haired_person": "👱", + "blond_haired_woman": "👱‍♀️", + "blonde_woman": "👱‍♀️", + "blossom": "🌼", + "blowfish": "🐡", + "blue_book": "📘", + "blue_car": "🚙", + "blue_heart": "💙", + "blue_square": "🟦", + "blueberries": "🫐", + "blush": "😊", + "boar": "🐗", + "boat": "⛵", + "bolivia": "🇧🇴", + "bomb": "💣", + "bone": "🦴", + "book": "📖", + "bookmark": "🔖", + "bookmark_tabs": "📑", + "books": "📚", + "boom": "💥", + "boomerang": "🪃", + "boot": "👢", + "bosnia_herzegovina": "🇧🇦", + "botswana": "🇧🇼", + "bouncing_ball_man": "⛹️‍♂️", + "bouncing_ball_person": "⛹️", + "bouncing_ball_woman": "⛹️‍♀️", + "bouquet": "💐", + "bouvet_island": "🇧🇻", + "bow": "🙇", + "bow_and_arrow": "🏹", + "bowing_man": "🙇‍♂️", + "bowing_woman": "🙇‍♀️", + "bowl_with_spoon": "🥣", + "bowling": "🎳", + "boxing_glove": "🥊", + "boy": "👦", + "brain": "🧠", + "brazil": "🇧🇷", + "bread": "🍞", + "breast_feeding": "🤱", + "bricks": "🧱", + "bride_with_veil": "👰‍♀️", + "bridge_at_night": "🌉", + "briefcase": "💼", + "british_indian_ocean_territory": "🇮🇴", + "british_virgin_islands": "🇻🇬", + "broccoli": "🥦", + "broken_heart": "💔", + "broom": "🧹", + "brown_circle": "🟤", + "brown_heart": "🤎", + "brown_square": "🟫", + "brunei": "🇧🇳", + "bubble_tea": "🧋", + "bucket": "🪣", + "bug": "🐛", + "building_construction": "🏗️", + "bulb": "💡", + "bulgaria": "🇧🇬", + "bullettrain_front": "🚅", + "bullettrain_side": "🚄", + "burkina_faso": "🇧🇫", + "burrito": "🌯", + "burundi": "🇧🇮", + "bus": "🚌", + "business_suit_levitating": "🕴️", + "busstop": "🚏", + "bust_in_silhouette": "👤", + "busts_in_silhouette": "👥", + "butter": "🧈", + "butterfly": "🦋", + "cactus": "🌵", + "cake": "🍰", + "calendar": "📆", + "call_me_hand": "🤙", + "calling": "📲", + "cambodia": "🇰🇭", + "camel": "🐫", + "camera": "📷", + "camera_flash": "📸", + "cameroon": "🇨🇲", + "camping": "🏕️", + "canada": "🇨🇦", + "canary_islands": "🇮🇨", + "cancer": "♋", + "candle": "🕯️", + "candy": "🍬", + "canned_food": "🥫", + "canoe": "🛶", + "cape_verde": "🇨🇻", + "capital_abcd": "🔠", + "capricorn": "♑", + "car": "🚗", + "card_file_box": "🗃️", + "card_index": "📇", + "card_index_dividers": "🗂️", + "caribbean_netherlands": "🇧🇶", + "carousel_horse": "🎠", + "carpentry_saw": "🪚", + "carrot": "🥕", + "cartwheeling": "🤸", + "cat": "🐱", + "cat2": "🐈", + "cayman_islands": "🇰🇾", + "cd": "💿", + "central_african_republic": "🇨🇫", + "ceuta_melilla": "🇪🇦", + "chad": "🇹🇩", + "chains": "⛓️", + "chair": "🪑", + "champagne": "🍾", + "chart": "💹", + "chart_with_downwards_trend": "📉", + "chart_with_upwards_trend": "📈", + "checkered_flag": "🏁", + "cheese": "🧀", + "cherries": "🍒", + "cherry_blossom": "🌸", + "chess_pawn": "♟️", + "chestnut": "🌰", + "chicken": "🐔", + "child": "🧒", + "children_crossing": "🚸", + "chile": "🇨🇱", + "chipmunk": "🐿️", + "chocolate_bar": "🍫", + "chopsticks": "🥢", + "christmas_island": "🇨🇽", + "christmas_tree": "🎄", + "church": "⛪", + "cinema": "🎦", + "circus_tent": "🎪", + "city_sunrise": "🌇", + "city_sunset": "🌆", + "cityscape": "🏙️", + "cl": "🆑", + "clamp": "🗜️", + "clap": "👏", + "clapper": "🎬", + "classical_building": "🏛️", + "climbing": "🧗", + "climbing_man": "🧗‍♂️", + "climbing_woman": "🧗‍♀️", + "clinking_glasses": "🥂", + "clipboard": "📋", + "clipperton_island": "🇨🇵", + "clock1": "🕐", + "clock10": "🕙", + "clock1030": "🕥", + "clock11": "🕚", + "clock1130": "🕦", + "clock12": "🕛", + "clock1230": "🕧", + "clock130": "🕜", + "clock2": "🕑", + "clock230": "🕝", + "clock3": "🕒", + "clock330": "🕞", + "clock4": "🕓", + "clock430": "🕟", + "clock5": "🕔", + "clock530": "🕠", + "clock6": "🕕", + "clock630": "🕡", + "clock7": "🕖", + "clock730": "🕢", + "clock8": "🕗", + "clock830": "🕣", + "clock9": "🕘", + "clock930": "🕤", + "closed_book": "📕", + "closed_lock_with_key": "🔐", + "closed_umbrella": "🌂", + "cloud": "☁️", + "cloud_with_lightning": "🌩️", + "cloud_with_lightning_and_rain": "⛈️", + "cloud_with_rain": "🌧️", + "cloud_with_snow": "🌨️", + "clown_face": "🤡", + "clubs": "♣️", + "cn": "🇨🇳", + "coat": "🧥", + "cockroach": "🪳", + "cocktail": "🍸", + "coconut": "🥥", + "cocos_islands": "🇨🇨", + "coffee": "☕", + "coffin": "⚰️", + "coin": "🪙", + "cold_face": "🥶", + "cold_sweat": "😰", + "collision": "💥", + "colombia": "🇨🇴", + "comet": "☄️", + "comoros": "🇰🇲", + "compass": "🧭", + "computer": "💻", + "computer_mouse": "🖱️", + "confetti_ball": "🎊", + "confounded": "😖", + "confused": "😕", + "congo_brazzaville": "🇨🇬", + "congo_kinshasa": "🇨🇩", + "congratulations": "㊗️", + "construction": "🚧", + "construction_worker": "👷", + "construction_worker_man": "👷‍♂️", + "construction_worker_woman": "👷‍♀️", + "control_knobs": "🎛️", + "convenience_store": "🏪", + "cook": "🧑‍🍳", + "cook_islands": "🇨🇰", + "cookie": "🍪", + "cool": "🆒", + "cop": "👮", + "copyright": "©️", + "corn": "🌽", + "costa_rica": "🇨🇷", + "cote_divoire": "🇨🇮", + "couch_and_lamp": "🛋️", + "couple": "👫", + "couple_with_heart": "💑", + "couple_with_heart_man_man": "👨‍❤️‍👨", + "couple_with_heart_woman_man": "👩‍❤️‍👨", + "couple_with_heart_woman_woman": "👩‍❤️‍👩", + "couplekiss": "💏", + "couplekiss_man_man": "👨‍❤️‍💋‍👨", + "couplekiss_man_woman": "👩‍❤️‍💋‍👨", + "couplekiss_woman_woman": "👩‍❤️‍💋‍👩", + "cow": "🐮", + "cow2": "🐄", + "cowboy_hat_face": "🤠", + "crab": "🦀", + "crayon": "🖍️", + "credit_card": "💳", + "crescent_moon": "🌙", + "cricket": "🦗", + "cricket_game": "🏏", + "croatia": "🇭🇷", + "crocodile": "🐊", + "croissant": "🥐", + "crossed_fingers": "🤞", + "crossed_flags": "🎌", + "crossed_swords": "⚔️", + "crown": "👑", + "cry": "😢", + "crying_cat_face": "😿", + "crystal_ball": "🔮", + "cuba": "🇨🇺", + "cucumber": "🥒", + "cup_with_straw": "🥤", + "cupcake": "🧁", + "cupid": "💘", + "curacao": "🇨🇼", + "curling_stone": "🥌", + "curly_haired_man": "👨‍🦱", + "curly_haired_woman": "👩‍🦱", + "curly_loop": "➰", + "currency_exchange": "💱", + "curry": "🍛", + "cursing_face": "🤬", + "custard": "🍮", + "customs": "🛃", + "cut_of_meat": "🥩", + "cyclone": "🌀", + "cyprus": "🇨🇾", + "czech_republic": "🇨🇿", + "dagger": "🗡️", + "dancer": "💃", + "dancers": "👯", + "dancing_men": "👯‍♂️", + "dancing_women": "👯‍♀️", + "dango": "🍡", + "dark_sunglasses": "🕶️", + "dart": "🎯", + "dash": "💨", + "date": "📅", + "de": "🇩🇪", + "deaf_man": "🧏‍♂️", + "deaf_person": "🧏", + "deaf_woman": "🧏‍♀️", + "deciduous_tree": "🌳", + "deer": "🦌", + "denmark": "🇩🇰", + "department_store": "🏬", + "derelict_house": "🏚️", + "desert": "🏜️", + "desert_island": "🏝️", + "desktop_computer": "🖥️", + "detective": "🕵️", + "diamond_shape_with_a_dot_inside": "💠", + "diamonds": "♦️", + "diego_garcia": "🇩🇬", + "disappointed": "😞", + "disappointed_relieved": "😥", + "disguised_face": "🥸", + "diving_mask": "🤿", + "diya_lamp": "🪔", + "dizzy": "💫", + "dizzy_face": "😵", + "djibouti": "🇩🇯", + "dna": "🧬", + "do_not_litter": "🚯", + "dodo": "🦤", + "dog": "🐶", + "dog2": "🐕", + "dollar": "💵", + "dolls": "🎎", + "dolphin": "🐬", + "dominica": "🇩🇲", + "dominican_republic": "🇩🇴", + "door": "🚪", + "doughnut": "🍩", + "dove": "🕊️", + "dragon": "🐉", + "dragon_face": "🐲", + "dress": "👗", + "dromedary_camel": "🐪", + "drooling_face": "🤤", + "drop_of_blood": "🩸", + "droplet": "💧", + "drum": "🥁", + "duck": "🦆", + "dumpling": "🥟", + "dvd": "📀", + "e-mail": "📧", + "eagle": "🦅", + "ear": "👂", + "ear_of_rice": "🌾", + "ear_with_hearing_aid": "🦻", + "earth_africa": "🌍", + "earth_americas": "🌎", + "earth_asia": "🌏", + "ecuador": "🇪🇨", + "egg": "🥚", + "eggplant": "🍆", + "egypt": "🇪🇬", + "eight": "8️⃣", + "eight_pointed_black_star": "✴️", + "eight_spoked_asterisk": "✳️", + "eject_button": "⏏️", + "el_salvador": "🇸🇻", + "electric_plug": "🔌", + "elephant": "🐘", + "elevator": "🛗", + "elf": "🧝", + "elf_man": "🧝‍♂️", + "elf_woman": "🧝‍♀️", + "email": "📧", + "end": "🔚", + "england": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "envelope": "✉️", + "envelope_with_arrow": "📩", + "equatorial_guinea": "🇬🇶", + "eritrea": "🇪🇷", + "es": "🇪🇸", + "estonia": "🇪🇪", + "ethiopia": "🇪🇹", + "eu": "🇪🇺", + "euro": "💶", + "european_castle": "🏰", + "european_post_office": "🏤", + "european_union": "🇪🇺", + "evergreen_tree": "🌲", + "exclamation": "❗", + "exploding_head": "🤯", + "expressionless": "😑", + "eye": "👁️", + "eye_speech_bubble": "👁️‍🗨️", + "eyeglasses": "👓", + "eyes": "👀", + "face_exhaling": "😮‍💨", + "face_in_clouds": "😶‍🌫️", + "face_with_head_bandage": "🤕", + "face_with_spiral_eyes": "😵‍💫", + "face_with_thermometer": "🤒", + "facepalm": "🤦", + "facepunch": "👊", + "factory": "🏭", + "factory_worker": "🧑‍🏭", + "fairy": "🧚", + "fairy_man": "🧚‍♂️", + "fairy_woman": "🧚‍♀️", + "falafel": "🧆", + "falkland_islands": "🇫🇰", + "fallen_leaf": "🍂", + "family": "👪", + "family_man_boy": "👨‍👦", + "family_man_boy_boy": "👨‍👦‍👦", + "family_man_girl": "👨‍👧", + "family_man_girl_boy": "👨‍👧‍👦", + "family_man_girl_girl": "👨‍👧‍👧", + "family_man_man_boy": "👨‍👨‍👦", + "family_man_man_boy_boy": "👨‍👨‍👦‍👦", + "family_man_man_girl": "👨‍👨‍👧", + "family_man_man_girl_boy": "👨‍👨‍👧‍👦", + "family_man_man_girl_girl": "👨‍👨‍👧‍👧", + "family_man_woman_boy": "👨‍👩‍👦", + "family_man_woman_boy_boy": "👨‍👩‍👦‍👦", + "family_man_woman_girl": "👨‍👩‍👧", + "family_man_woman_girl_boy": "👨‍👩‍👧‍👦", + "family_man_woman_girl_girl": "👨‍👩‍👧‍👧", + "family_woman_boy": "👩‍👦", + "family_woman_boy_boy": "👩‍👦‍👦", + "family_woman_girl": "👩‍👧", + "family_woman_girl_boy": "👩‍👧‍👦", + "family_woman_girl_girl": "👩‍👧‍👧", + "family_woman_woman_boy": "👩‍👩‍👦", + "family_woman_woman_boy_boy": "👩‍👩‍👦‍👦", + "family_woman_woman_girl": "👩‍👩‍👧", + "family_woman_woman_girl_boy": "👩‍👩‍👧‍👦", + "family_woman_woman_girl_girl": "👩‍👩‍👧‍👧", + "farmer": "🧑‍🌾", + "faroe_islands": "🇫🇴", + "fast_forward": "⏩", + "fax": "📠", + "fearful": "😨", + "feather": "🪶", + "feet": "🐾", + "female_detective": "🕵️‍♀️", + "female_sign": "♀️", + "ferris_wheel": "🎡", + "ferry": "⛴️", + "field_hockey": "🏑", + "fiji": "🇫🇯", + "file_cabinet": "🗄️", + "file_folder": "📁", + "film_projector": "📽️", + "film_strip": "🎞️", + "finland": "🇫🇮", + "fire": "🔥", + "fire_engine": "🚒", + "fire_extinguisher": "🧯", + "firecracker": "🧨", + "firefighter": "🧑‍🚒", + "fireworks": "🎆", + "first_quarter_moon": "🌓", + "first_quarter_moon_with_face": "🌛", + "fish": "🐟", + "fish_cake": "🍥", + "fishing_pole_and_fish": "🎣", + "fist": "✊", + "fist_left": "🤛", + "fist_oncoming": "👊", + "fist_raised": "✊", + "fist_right": "🤜", + "five": "5️⃣", + "flags": "🎏", + "flamingo": "🦩", + "flashlight": "🔦", + "flat_shoe": "🥿", + "flatbread": "🫓", + "fleur_de_lis": "⚜️", + "flight_arrival": "🛬", + "flight_departure": "🛫", + "flipper": "🐬", + "floppy_disk": "💾", + "flower_playing_cards": "🎴", + "flushed": "😳", + "fly": "🪰", + "flying_disc": "🥏", + "flying_saucer": "🛸", + "fog": "🌫️", + "foggy": "🌁", + "fondue": "🫕", + "foot": "🦶", + "football": "🏈", + "footprints": "👣", + "fork_and_knife": "🍴", + "fortune_cookie": "🥠", + "fountain": "⛲", + "fountain_pen": "🖋️", + "four": "4️⃣", + "four_leaf_clover": "🍀", + "fox_face": "🦊", + "fr": "🇫🇷", + "framed_picture": "🖼️", + "free": "🆓", + "french_guiana": "🇬🇫", + "french_polynesia": "🇵🇫", + "french_southern_territories": "🇹🇫", + "fried_egg": "🍳", + "fried_shrimp": "🍤", + "fries": "🍟", + "frog": "🐸", + "frowning": "😦", + "frowning_face": "☹️", + "frowning_man": "🙍‍♂️", + "frowning_person": "🙍", + "frowning_woman": "🙍‍♀️", + "fu": "🖕", + "fuelpump": "⛽", + "full_moon": "🌕", + "full_moon_with_face": "🌝", + "funeral_urn": "⚱️", + "gabon": "🇬🇦", + "gambia": "🇬🇲", + "game_die": "🎲", + "garlic": "🧄", + "gb": "🇬🇧", + "gear": "⚙️", + "gem": "💎", + "gemini": "♊", + "genie": "🧞", + "genie_man": "🧞‍♂️", + "genie_woman": "🧞‍♀️", + "georgia": "🇬🇪", + "ghana": "🇬🇭", + "ghost": "👻", + "gibraltar": "🇬🇮", + "gift": "🎁", + "gift_heart": "💝", + "giraffe": "🦒", + "girl": "👧", + "globe_with_meridians": "🌐", + "gloves": "🧤", + "goal_net": "🥅", + "goat": "🐐", + "goggles": "🥽", + "golf": "⛳", + "golfing": "🏌️", + "golfing_man": "🏌️‍♂️", + "golfing_woman": "🏌️‍♀️", + "gorilla": "🦍", + "grapes": "🍇", + "greece": "🇬🇷", + "green_apple": "🍏", + "green_book": "📗", + "green_circle": "🟢", + "green_heart": "💚", + "green_salad": "🥗", + "green_square": "🟩", + "greenland": "🇬🇱", + "grenada": "🇬🇩", + "grey_exclamation": "❕", + "grey_question": "❔", + "grimacing": "😬", + "grin": "😁", + "grinning": "😀", + "guadeloupe": "🇬🇵", + "guam": "🇬🇺", + "guard": "💂", + "guardsman": "💂‍♂️", + "guardswoman": "💂‍♀️", + "guatemala": "🇬🇹", + "guernsey": "🇬🇬", + "guide_dog": "🦮", + "guinea": "🇬🇳", + "guinea_bissau": "🇬🇼", + "guitar": "🎸", + "gun": "🔫", + "guyana": "🇬🇾", + "haircut": "💇", + "haircut_man": "💇‍♂️", + "haircut_woman": "💇‍♀️", + "haiti": "🇭🇹", + "hamburger": "🍔", + "hammer": "🔨", + "hammer_and_pick": "⚒️", + "hammer_and_wrench": "🛠️", + "hamster": "🐹", + "hand": "✋", + "hand_over_mouth": "🤭", + "handbag": "👜", + "handball_person": "🤾", + "handshake": "🤝", + "hankey": "💩", + "hash": "#️⃣", + "hatched_chick": "🐥", + "hatching_chick": "🐣", + "headphones": "🎧", + "headstone": "🪦", + "health_worker": "🧑‍⚕️", + "hear_no_evil": "🙉", + "heard_mcdonald_islands": "🇭🇲", + "heart": "❤️", + "heart_decoration": "💟", + "heart_eyes": "😍", + "heart_eyes_cat": "😻", + "heart_on_fire": "❤️‍🔥", + "heartbeat": "💓", + "heartpulse": "💗", + "hearts": "♥️", + "heavy_check_mark": "✔️", + "heavy_division_sign": "➗", + "heavy_dollar_sign": "💲", + "heavy_exclamation_mark": "❗", + "heavy_heart_exclamation": "❣️", + "heavy_minus_sign": "➖", + "heavy_multiplication_x": "✖️", + "heavy_plus_sign": "➕", + "hedgehog": "🦔", + "helicopter": "🚁", + "herb": "🌿", + "hibiscus": "🌺", + "high_brightness": "🔆", + "high_heel": "👠", + "hiking_boot": "🥾", + "hindu_temple": "🛕", + "hippopotamus": "🦛", + "hocho": "🔪", + "hole": "🕳️", + "honduras": "🇭🇳", + "honey_pot": "🍯", + "honeybee": "🐝", + "hong_kong": "🇭🇰", + "hook": "🪝", + "horse": "🐴", + "horse_racing": "🏇", + "hospital": "🏥", + "hot_face": "🥵", + "hot_pepper": "🌶️", + "hotdog": "🌭", + "hotel": "🏨", + "hotsprings": "♨️", + "hourglass": "⌛", + "hourglass_flowing_sand": "⏳", + "house": "🏠", + "house_with_garden": "🏡", + "houses": "🏘️", + "hugs": "🤗", + "hungary": "🇭🇺", + "hushed": "😯", + "hut": "🛖", + "ice_cream": "🍨", + "ice_cube": "🧊", + "ice_hockey": "🏒", + "ice_skate": "⛸️", + "icecream": "🍦", + "iceland": "🇮🇸", + "id": "🆔", + "ideograph_advantage": "🉐", + "imp": "👿", + "inbox_tray": "📥", + "incoming_envelope": "📨", + "india": "🇮🇳", + "indonesia": "🇮🇩", + "infinity": "♾️", + "information_desk_person": "💁", + "information_source": "ℹ️", + "innocent": "😇", + "interrobang": "⁉️", + "iphone": "📱", + "iran": "🇮🇷", + "iraq": "🇮🇶", + "ireland": "🇮🇪", + "isle_of_man": "🇮🇲", + "israel": "🇮🇱", + "it": "🇮🇹", + "izakaya_lantern": "🏮", + "jack_o_lantern": "🎃", + "jamaica": "🇯🇲", + "japan": "🗾", + "japanese_castle": "🏯", + "japanese_goblin": "👺", + "japanese_ogre": "👹", + "jeans": "👖", + "jersey": "🇯🇪", + "jigsaw": "🧩", + "jordan": "🇯🇴", + "joy": "😂", + "joy_cat": "😹", + "joystick": "🕹️", + "jp": "🇯🇵", + "judge": "🧑‍⚖️", + "juggling_person": "🤹", + "kaaba": "🕋", + "kangaroo": "🦘", + "kazakhstan": "🇰🇿", + "kenya": "🇰🇪", + "key": "🔑", + "keyboard": "⌨️", + "keycap_ten": "🔟", + "kick_scooter": "🛴", + "kimono": "👘", + "kiribati": "🇰🇮", + "kiss": "💋", + "kissing": "😗", + "kissing_cat": "😽", + "kissing_closed_eyes": "😚", + "kissing_heart": "😘", + "kissing_smiling_eyes": "😙", + "kite": "🪁", + "kiwi_fruit": "🥝", + "kneeling_man": "🧎‍♂️", + "kneeling_person": "🧎", + "kneeling_woman": "🧎‍♀️", + "knife": "🔪", + "knot": "🪢", + "koala": "🐨", + "koko": "🈁", + "kosovo": "🇽🇰", + "kr": "🇰🇷", + "kuwait": "🇰🇼", + "kyrgyzstan": "🇰🇬", + "lab_coat": "🥼", + "label": "🏷️", + "lacrosse": "🥍", + "ladder": "🪜", + "lady_beetle": "🐞", + "lantern": "🏮", + "laos": "🇱🇦", + "large_blue_circle": "🔵", + "large_blue_diamond": "🔷", + "large_orange_diamond": "🔶", + "last_quarter_moon": "🌗", + "last_quarter_moon_with_face": "🌜", + "latin_cross": "✝️", + "latvia": "🇱🇻", + "laughing": "😆", + "leafy_green": "🥬", + "leaves": "🍃", + "lebanon": "🇱🇧", + "ledger": "📒", + "left_luggage": "🛅", + "left_right_arrow": "↔️", + "left_speech_bubble": "🗨️", + "leftwards_arrow_with_hook": "↩️", + "leg": "🦵", + "lemon": "🍋", + "leo": "♌", + "leopard": "🐆", + "lesotho": "🇱🇸", + "level_slider": "🎚️", + "liberia": "🇱🇷", + "libra": "♎", + "libya": "🇱🇾", + "liechtenstein": "🇱🇮", + "light_rail": "🚈", + "link": "🔗", + "lion": "🦁", + "lips": "👄", + "lipstick": "💄", + "lithuania": "🇱🇹", + "lizard": "🦎", + "llama": "🦙", + "lobster": "🦞", + "lock": "🔒", + "lock_with_ink_pen": "🔏", + "lollipop": "🍭", + "long_drum": "🪘", + "loop": "➿", + "lotion_bottle": "🧴", + "lotus_position": "🧘", + "lotus_position_man": "🧘‍♂️", + "lotus_position_woman": "🧘‍♀️", + "loud_sound": "🔊", + "loudspeaker": "📢", + "love_hotel": "🏩", + "love_letter": "💌", + "love_you_gesture": "🤟", + "low_brightness": "🔅", + "luggage": "🧳", + "lungs": "🫁", + "luxembourg": "🇱🇺", + "lying_face": "🤥", + "m": "Ⓜ️", + "macau": "🇲🇴", + "macedonia": "🇲🇰", + "madagascar": "🇲🇬", + "mag": "🔍", + "mag_right": "🔎", + "mage": "🧙", + "mage_man": "🧙‍♂️", + "mage_woman": "🧙‍♀️", + "magic_wand": "🪄", + "magnet": "🧲", + "mahjong": "🀄", + "mailbox": "📫", + "mailbox_closed": "📪", + "mailbox_with_mail": "📬", + "mailbox_with_no_mail": "📭", + "malawi": "🇲🇼", + "malaysia": "🇲🇾", + "maldives": "🇲🇻", + "male_detective": "🕵️‍♂️", + "male_sign": "♂️", + "mali": "🇲🇱", + "malta": "🇲🇹", + "mammoth": "🦣", + "man": "👨", + "man_artist": "👨‍🎨", + "man_astronaut": "👨‍🚀", + "man_beard": "🧔‍♂️", + "man_cartwheeling": "🤸‍♂️", + "man_cook": "👨‍🍳", + "man_dancing": "🕺", + "man_facepalming": "🤦‍♂️", + "man_factory_worker": "👨‍🏭", + "man_farmer": "👨‍🌾", + "man_feeding_baby": "👨‍🍼", + "man_firefighter": "👨‍🚒", + "man_health_worker": "👨‍⚕️", + "man_in_manual_wheelchair": "👨‍🦽", + "man_in_motorized_wheelchair": "👨‍🦼", + "man_in_tuxedo": "🤵‍♂️", + "man_judge": "👨‍⚖️", + "man_juggling": "🤹‍♂️", + "man_mechanic": "👨‍🔧", + "man_office_worker": "👨‍💼", + "man_pilot": "👨‍✈️", + "man_playing_handball": "🤾‍♂️", + "man_playing_water_polo": "🤽‍♂️", + "man_scientist": "👨‍🔬", + "man_shrugging": "🤷‍♂️", + "man_singer": "👨‍🎤", + "man_student": "👨‍🎓", + "man_teacher": "👨‍🏫", + "man_technologist": "👨‍💻", + "man_with_gua_pi_mao": "👲", + "man_with_probing_cane": "👨‍🦯", + "man_with_turban": "👳‍♂️", + "man_with_veil": "👰‍♂️", + "mandarin": "🍊", + "mango": "🥭", + "mans_shoe": "👞", + "mantelpiece_clock": "🕰️", + "manual_wheelchair": "🦽", + "maple_leaf": "🍁", + "marshall_islands": "🇲🇭", + "martial_arts_uniform": "🥋", + "martinique": "🇲🇶", + "mask": "😷", + "massage": "💆", + "massage_man": "💆‍♂️", + "massage_woman": "💆‍♀️", + "mate": "🧉", + "mauritania": "🇲🇷", + "mauritius": "🇲🇺", + "mayotte": "🇾🇹", + "meat_on_bone": "🍖", + "mechanic": "🧑‍🔧", + "mechanical_arm": "🦾", + "mechanical_leg": "🦿", + "medal_military": "🎖️", + "medal_sports": "🏅", + "medical_symbol": "⚕️", + "mega": "📣", + "melon": "🍈", + "memo": "📝", + "men_wrestling": "🤼‍♂️", + "mending_heart": "❤️‍🩹", + "menorah": "🕎", + "mens": "🚹", + "mermaid": "🧜‍♀️", + "merman": "🧜‍♂️", + "merperson": "🧜", + "metal": "🤘", + "metro": "🚇", + "mexico": "🇲🇽", + "microbe": "🦠", + "micronesia": "🇫🇲", + "microphone": "🎤", + "microscope": "🔬", + "middle_finger": "🖕", + "military_helmet": "🪖", + "milk_glass": "🥛", + "milky_way": "🌌", + "minibus": "🚐", + "minidisc": "💽", + "mirror": "🪞", + "mobile_phone_off": "📴", + "moldova": "🇲🇩", + "monaco": "🇲🇨", + "money_mouth_face": "🤑", + "money_with_wings": "💸", + "moneybag": "💰", + "mongolia": "🇲🇳", + "monkey": "🐒", + "monkey_face": "🐵", + "monocle_face": "🧐", + "monorail": "🚝", + "montenegro": "🇲🇪", + "montserrat": "🇲🇸", + "moon": "🌔", + "moon_cake": "🥮", + "morocco": "🇲🇦", + "mortar_board": "🎓", + "mosque": "🕌", + "mosquito": "🦟", + "motor_boat": "🛥️", + "motor_scooter": "🛵", + "motorcycle": "🏍️", + "motorized_wheelchair": "🦼", + "motorway": "🛣️", + "mount_fuji": "🗻", + "mountain": "⛰️", + "mountain_bicyclist": "🚵", + "mountain_biking_man": "🚵‍♂️", + "mountain_biking_woman": "🚵‍♀️", + "mountain_cableway": "🚠", + "mountain_railway": "🚞", + "mountain_snow": "🏔️", + "mouse": "🐭", + "mouse2": "🐁", + "mouse_trap": "🪤", + "movie_camera": "🎥", + "moyai": "🗿", + "mozambique": "🇲🇿", + "mrs_claus": "🤶", + "muscle": "💪", + "mushroom": "🍄", + "musical_keyboard": "🎹", + "musical_note": "🎵", + "musical_score": "🎼", + "mute": "🔇", + "mx_claus": "🧑‍🎄", + "myanmar": "🇲🇲", + "nail_care": "💅", + "name_badge": "📛", + "namibia": "🇳🇦", + "national_park": "🏞️", + "nauru": "🇳🇷", + "nauseated_face": "🤢", + "nazar_amulet": "🧿", + "necktie": "👔", + "negative_squared_cross_mark": "❎", + "nepal": "🇳🇵", + "nerd_face": "🤓", + "nesting_dolls": "🪆", + "netherlands": "🇳🇱", + "neutral_face": "😐", + "new": "🆕", + "new_caledonia": "🇳🇨", + "new_moon": "🌑", + "new_moon_with_face": "🌚", + "new_zealand": "🇳🇿", + "newspaper": "📰", + "newspaper_roll": "🗞️", + "next_track_button": "⏭️", + "ng": "🆖", + "ng_man": "🙅‍♂️", + "ng_woman": "🙅‍♀️", + "nicaragua": "🇳🇮", + "niger": "🇳🇪", + "nigeria": "🇳🇬", + "night_with_stars": "🌃", + "nine": "9️⃣", + "ninja": "🥷", + "niue": "🇳🇺", + "no_bell": "🔕", + "no_bicycles": "🚳", + "no_entry": "⛔", + "no_entry_sign": "🚫", + "no_good": "🙅", + "no_good_man": "🙅‍♂️", + "no_good_woman": "🙅‍♀️", + "no_mobile_phones": "📵", + "no_mouth": "😶", + "no_pedestrians": "🚷", + "no_smoking": "🚭", + "non-potable_water": "🚱", + "norfolk_island": "🇳🇫", + "north_korea": "🇰🇵", + "northern_mariana_islands": "🇲🇵", + "norway": "🇳🇴", + "nose": "👃", + "notebook": "📓", + "notebook_with_decorative_cover": "📔", + "notes": "🎶", + "nut_and_bolt": "🔩", + "o": "⭕", + "o2": "🅾️", + "ocean": "🌊", + "octopus": "🐙", + "oden": "🍢", + "office": "🏢", + "office_worker": "🧑‍💼", + "oil_drum": "🛢️", + "ok": "🆗", + "ok_hand": "👌", + "ok_man": "🙆‍♂️", + "ok_person": "🙆", + "ok_woman": "🙆‍♀️", + "old_key": "🗝️", + "older_adult": "🧓", + "older_man": "👴", + "older_woman": "👵", + "olive": "🫒", + "om": "🕉️", + "oman": "🇴🇲", + "on": "🔛", + "oncoming_automobile": "🚘", + "oncoming_bus": "🚍", + "oncoming_police_car": "🚔", + "oncoming_taxi": "🚖", + "one": "1️⃣", + "one_piece_swimsuit": "🩱", + "onion": "🧅", + "open_book": "📖", + "open_file_folder": "📂", + "open_hands": "👐", + "open_mouth": "😮", + "open_umbrella": "☂️", + "ophiuchus": "⛎", + "orange": "🍊", + "orange_book": "📙", + "orange_circle": "🟠", + "orange_heart": "🧡", + "orange_square": "🟧", + "orangutan": "🦧", + "orthodox_cross": "☦️", + "otter": "🦦", + "outbox_tray": "📤", + "owl": "🦉", + "ox": "🐂", + "oyster": "🦪", + "package": "📦", + "page_facing_up": "📄", + "page_with_curl": "📃", + "pager": "📟", + "paintbrush": "🖌️", + "pakistan": "🇵🇰", + "palau": "🇵🇼", + "palestinian_territories": "🇵🇸", + "palm_tree": "🌴", + "palms_up_together": "🤲", + "panama": "🇵🇦", + "pancakes": "🥞", + "panda_face": "🐼", + "paperclip": "📎", + "paperclips": "🖇️", + "papua_new_guinea": "🇵🇬", + "parachute": "🪂", + "paraguay": "🇵🇾", + "parasol_on_ground": "⛱️", + "parking": "🅿️", + "parrot": "🦜", + "part_alternation_mark": "〽️", + "partly_sunny": "⛅", + "partying_face": "🥳", + "passenger_ship": "🛳️", + "passport_control": "🛂", + "pause_button": "⏸️", + "paw_prints": "🐾", + "peace_symbol": "☮️", + "peach": "🍑", + "peacock": "🦚", + "peanuts": "🥜", + "pear": "🍐", + "pen": "🖊️", + "pencil": "📝", + "pencil2": "✏️", + "penguin": "🐧", + "pensive": "😔", + "people_holding_hands": "🧑‍🤝‍🧑", + "people_hugging": "🫂", + "performing_arts": "🎭", + "persevere": "😣", + "person_bald": "🧑‍🦲", + "person_curly_hair": "🧑‍🦱", + "person_feeding_baby": "🧑‍🍼", + "person_fencing": "🤺", + "person_in_manual_wheelchair": "🧑‍🦽", + "person_in_motorized_wheelchair": "🧑‍🦼", + "person_in_tuxedo": "🤵", + "person_red_hair": "🧑‍🦰", + "person_white_hair": "🧑‍🦳", + "person_with_probing_cane": "🧑‍🦯", + "person_with_turban": "👳", + "person_with_veil": "👰", + "peru": "🇵🇪", + "petri_dish": "🧫", + "philippines": "🇵🇭", + "phone": "☎️", + "pick": "⛏️", + "pickup_truck": "🛻", + "pie": "🥧", + "pig": "🐷", + "pig2": "🐖", + "pig_nose": "🐽", + "pill": "💊", + "pilot": "🧑‍✈️", + "pinata": "🪅", + "pinched_fingers": "🤌", + "pinching_hand": "🤏", + "pineapple": "🍍", + "ping_pong": "🏓", + "pirate_flag": "🏴‍☠️", + "pisces": "♓", + "pitcairn_islands": "🇵🇳", + "pizza": "🍕", + "placard": "🪧", + "place_of_worship": "🛐", + "plate_with_cutlery": "🍽️", + "play_or_pause_button": "⏯️", + "pleading_face": "🥺", + "plunger": "🪠", + "point_down": "👇", + "point_left": "👈", + "point_right": "👉", + "point_up": "☝️", + "point_up_2": "👆", + "poland": "🇵🇱", + "polar_bear": "🐻‍❄️", + "police_car": "🚓", + "police_officer": "👮", + "policeman": "👮‍♂️", + "policewoman": "👮‍♀️", + "poodle": "🐩", + "poop": "💩", + "popcorn": "🍿", + "portugal": "🇵🇹", + "post_office": "🏣", + "postal_horn": "📯", + "postbox": "📮", + "potable_water": "🚰", + "potato": "🥔", + "potted_plant": "🪴", + "pouch": "👝", + "poultry_leg": "🍗", + "pound": "💷", + "pout": "😡", + "pouting_cat": "😾", + "pouting_face": "🙎", + "pouting_man": "🙎‍♂️", + "pouting_woman": "🙎‍♀️", + "pray": "🙏", + "prayer_beads": "📿", + "pregnant_woman": "🤰", + "pretzel": "🥨", + "previous_track_button": "⏮️", + "prince": "🤴", + "princess": "👸", + "printer": "🖨️", + "probing_cane": "🦯", + "puerto_rico": "🇵🇷", + "punch": "👊", + "purple_circle": "🟣", + "purple_heart": "💜", + "purple_square": "🟪", + "purse": "👛", + "pushpin": "📌", + "put_litter_in_its_place": "🚮", + "qatar": "🇶🇦", + "question": "❓", + "rabbit": "🐰", + "rabbit2": "🐇", + "raccoon": "🦝", + "racehorse": "🐎", + "racing_car": "🏎️", + "radio": "📻", + "radio_button": "🔘", + "radioactive": "☢️", + "rage": "😡", + "railway_car": "🚃", + "railway_track": "🛤️", + "rainbow": "🌈", + "rainbow_flag": "🏳️‍🌈", + "raised_back_of_hand": "🤚", + "raised_eyebrow": "🤨", + "raised_hand": "✋", + "raised_hand_with_fingers_splayed": "🖐️", + "raised_hands": "🙌", + "raising_hand": "🙋", + "raising_hand_man": "🙋‍♂️", + "raising_hand_woman": "🙋‍♀️", + "ram": "🐏", + "ramen": "🍜", + "rat": "🐀", + "razor": "🪒", + "receipt": "🧾", + "record_button": "⏺️", + "recycle": "♻️", + "red_car": "🚗", + "red_circle": "🔴", + "red_envelope": "🧧", + "red_haired_man": "👨‍🦰", + "red_haired_woman": "👩‍🦰", + "red_square": "🟥", + "registered": "®️", + "relaxed": "☺️", + "relieved": "😌", + "reminder_ribbon": "🎗️", + "repeat": "🔁", + "repeat_one": "🔂", + "rescue_worker_helmet": "⛑️", + "restroom": "🚻", + "reunion": "🇷🇪", + "revolving_hearts": "💞", + "rewind": "⏪", + "rhinoceros": "🦏", + "ribbon": "🎀", + "rice": "🍚", + "rice_ball": "🍙", + "rice_cracker": "🍘", + "rice_scene": "🎑", + "right_anger_bubble": "🗯️", + "ring": "💍", + "ringed_planet": "🪐", + "robot": "🤖", + "rock": "🪨", + "rocket": "🚀", + "rofl": "🤣", + "roll_eyes": "🙄", + "roll_of_paper": "🧻", + "roller_coaster": "🎢", + "roller_skate": "🛼", + "romania": "🇷🇴", + "rooster": "🐓", + "rose": "🌹", + "rosette": "🏵️", + "rotating_light": "🚨", + "round_pushpin": "📍", + "rowboat": "🚣", + "rowing_man": "🚣‍♂️", + "rowing_woman": "🚣‍♀️", + "ru": "🇷🇺", + "rugby_football": "🏉", + "runner": "🏃", + "running": "🏃", + "running_man": "🏃‍♂️", + "running_shirt_with_sash": "🎽", + "running_woman": "🏃‍♀️", + "rwanda": "🇷🇼", + "sa": "🈂️", + "safety_pin": "🧷", + "safety_vest": "🦺", + "sagittarius": "♐", + "sailboat": "⛵", + "sake": "🍶", + "salt": "🧂", + "samoa": "🇼🇸", + "san_marino": "🇸🇲", + "sandal": "👡", + "sandwich": "🥪", + "santa": "🎅", + "sao_tome_principe": "🇸🇹", + "sari": "🥻", + "sassy_man": "💁‍♂️", + "sassy_woman": "💁‍♀️", + "satellite": "📡", + "satisfied": "😆", + "saudi_arabia": "🇸🇦", + "sauna_man": "🧖‍♂️", + "sauna_person": "🧖", + "sauna_woman": "🧖‍♀️", + "sauropod": "🦕", + "saxophone": "🎷", + "scarf": "🧣", + "school": "🏫", + "school_satchel": "🎒", + "scientist": "🧑‍🔬", + "scissors": "✂️", + "scorpion": "🦂", + "scorpius": "♏", + "scotland": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "scream": "😱", + "scream_cat": "🙀", + "screwdriver": "🪛", + "scroll": "📜", + "seal": "🦭", + "seat": "💺", + "secret": "㊙️", + "see_no_evil": "🙈", + "seedling": "🌱", + "selfie": "🤳", + "senegal": "🇸🇳", + "serbia": "🇷🇸", + "service_dog": "🐕‍🦺", + "seven": "7️⃣", + "sewing_needle": "🪡", + "seychelles": "🇸🇨", + "shallow_pan_of_food": "🥘", + "shamrock": "☘️", + "shark": "🦈", + "shaved_ice": "🍧", + "sheep": "🐑", + "shell": "🐚", + "shield": "🛡️", + "shinto_shrine": "⛩️", + "ship": "🚢", + "shirt": "👕", + "shit": "💩", + "shoe": "👞", + "shopping": "🛍️", + "shopping_cart": "🛒", + "shorts": "🩳", + "shower": "🚿", + "shrimp": "🦐", + "shrug": "🤷", + "shushing_face": "🤫", + "sierra_leone": "🇸🇱", + "signal_strength": "📶", + "singapore": "🇸🇬", + "singer": "🧑‍🎤", + "sint_maarten": "🇸🇽", + "six": "6️⃣", + "six_pointed_star": "🔯", + "skateboard": "🛹", + "ski": "🎿", + "skier": "⛷️", + "skull": "💀", + "skull_and_crossbones": "☠️", + "skunk": "🦨", + "sled": "🛷", + "sleeping": "😴", + "sleeping_bed": "🛌", + "sleepy": "😪", + "slightly_frowning_face": "🙁", + "slightly_smiling_face": "🙂", + "slot_machine": "🎰", + "sloth": "🦥", + "slovakia": "🇸🇰", + "slovenia": "🇸🇮", + "small_airplane": "🛩️", + "small_blue_diamond": "🔹", + "small_orange_diamond": "🔸", + "small_red_triangle": "🔺", + "small_red_triangle_down": "🔻", + "smile": "😄", + "smile_cat": "😸", + "smiley": "😃", + "smiley_cat": "😺", + "smiling_face_with_tear": "🥲", + "smiling_face_with_three_hearts": "🥰", + "smiling_imp": "😈", + "smirk": "😏", + "smirk_cat": "😼", + "smoking": "🚬", + "snail": "🐌", + "snake": "🐍", + "sneezing_face": "🤧", + "snowboarder": "🏂", + "snowflake": "❄️", + "snowman": "⛄", + "snowman_with_snow": "☃️", + "soap": "🧼", + "sob": "😭", + "soccer": "⚽", + "socks": "🧦", + "softball": "🥎", + "solomon_islands": "🇸🇧", + "somalia": "🇸🇴", + "soon": "🔜", + "sos": "🆘", + "sound": "🔉", + "south_africa": "🇿🇦", + "south_georgia_south_sandwich_islands": "🇬🇸", + "south_sudan": "🇸🇸", + "space_invader": "👾", + "spades": "♠️", + "spaghetti": "🍝", + "sparkle": "❇️", + "sparkler": "🎇", + "sparkles": "✨", + "sparkling_heart": "💖", + "speak_no_evil": "🙊", + "speaker": "🔈", + "speaking_head": "🗣️", + "speech_balloon": "💬", + "speedboat": "🚤", + "spider": "🕷️", + "spider_web": "🕸️", + "spiral_calendar": "🗓️", + "spiral_notepad": "🗒️", + "sponge": "🧽", + "spoon": "🥄", + "squid": "🦑", + "sri_lanka": "🇱🇰", + "st_barthelemy": "🇧🇱", + "st_helena": "🇸🇭", + "st_kitts_nevis": "🇰🇳", + "st_lucia": "🇱🇨", + "st_martin": "🇲🇫", + "st_pierre_miquelon": "🇵🇲", + "st_vincent_grenadines": "🇻🇨", + "stadium": "🏟️", + "standing_man": "🧍‍♂️", + "standing_person": "🧍", + "standing_woman": "🧍‍♀️", + "star": "⭐", + "star2": "🌟", + "star_and_crescent": "☪️", + "star_of_david": "✡️", + "star_struck": "🤩", + "stars": "🌠", + "station": "🚉", + "statue_of_liberty": "🗽", + "steam_locomotive": "🚂", + "stethoscope": "🩺", + "stew": "🍲", + "stop_button": "⏹️", + "stop_sign": "🛑", + "stopwatch": "⏱️", + "straight_ruler": "📏", + "strawberry": "🍓", + "stuck_out_tongue": "😛", + "stuck_out_tongue_closed_eyes": "😝", + "stuck_out_tongue_winking_eye": "😜", + "student": "🧑‍🎓", + "studio_microphone": "🎙️", + "stuffed_flatbread": "🥙", + "sudan": "🇸🇩", + "sun_behind_large_cloud": "🌥️", + "sun_behind_rain_cloud": "🌦️", + "sun_behind_small_cloud": "🌤️", + "sun_with_face": "🌞", + "sunflower": "🌻", + "sunglasses": "😎", + "sunny": "☀️", + "sunrise": "🌅", + "sunrise_over_mountains": "🌄", + "superhero": "🦸", + "superhero_man": "🦸‍♂️", + "superhero_woman": "🦸‍♀️", + "supervillain": "🦹", + "supervillain_man": "🦹‍♂️", + "supervillain_woman": "🦹‍♀️", + "surfer": "🏄", + "surfing_man": "🏄‍♂️", + "surfing_woman": "🏄‍♀️", + "suriname": "🇸🇷", + "sushi": "🍣", + "suspension_railway": "🚟", + "svalbard_jan_mayen": "🇸🇯", + "swan": "🦢", + "swaziland": "🇸🇿", + "sweat": "😓", + "sweat_drops": "💦", + "sweat_smile": "😅", + "sweden": "🇸🇪", + "sweet_potato": "🍠", + "swim_brief": "🩲", + "swimmer": "🏊", + "swimming_man": "🏊‍♂️", + "swimming_woman": "🏊‍♀️", + "switzerland": "🇨🇭", + "symbols": "🔣", + "synagogue": "🕍", + "syria": "🇸🇾", + "syringe": "💉", + "t-rex": "🦖", + "taco": "🌮", + "tada": "🎉", + "taiwan": "🇹🇼", + "tajikistan": "🇹🇯", + "takeout_box": "🥡", + "tamale": "🫔", + "tanabata_tree": "🎋", + "tangerine": "🍊", + "tanzania": "🇹🇿", + "taurus": "♉", + "taxi": "🚕", + "tea": "🍵", + "teacher": "🧑‍🏫", + "teapot": "🫖", + "technologist": "🧑‍💻", + "teddy_bear": "🧸", + "telephone": "☎️", + "telephone_receiver": "📞", + "telescope": "🔭", + "tennis": "🎾", + "tent": "⛺", + "test_tube": "🧪", + "thailand": "🇹🇭", + "thermometer": "🌡️", + "thinking": "🤔", + "thong_sandal": "🩴", + "thought_balloon": "💭", + "thread": "🧵", + "three": "3️⃣", + "thumbsdown": "👎", + "thumbsup": "👍", + "ticket": "🎫", + "tickets": "🎟️", + "tiger": "🐯", + "tiger2": "🐅", + "timer_clock": "⏲️", + "timor_leste": "🇹🇱", + "tipping_hand_man": "💁‍♂️", + "tipping_hand_person": "💁", + "tipping_hand_woman": "💁‍♀️", + "tired_face": "😫", + "tm": "™️", + "togo": "🇹🇬", + "toilet": "🚽", + "tokelau": "🇹🇰", + "tokyo_tower": "🗼", + "tomato": "🍅", + "tonga": "🇹🇴", + "tongue": "👅", + "toolbox": "🧰", + "tooth": "🦷", + "toothbrush": "🪥", + "top": "🔝", + "tophat": "🎩", + "tornado": "🌪️", + "tr": "🇹🇷", + "trackball": "🖲️", + "tractor": "🚜", + "traffic_light": "🚥", + "train": "🚋", + "train2": "🚆", + "tram": "🚊", + "transgender_flag": "🏳️‍⚧️", + "transgender_symbol": "⚧️", + "triangular_flag_on_post": "🚩", + "triangular_ruler": "📐", + "trident": "🔱", + "trinidad_tobago": "🇹🇹", + "tristan_da_cunha": "🇹🇦", + "triumph": "😤", + "trolleybus": "🚎", + "trophy": "🏆", + "tropical_drink": "🍹", + "tropical_fish": "🐠", + "truck": "🚚", + "trumpet": "🎺", + "tshirt": "👕", + "tulip": "🌷", + "tumbler_glass": "🥃", + "tunisia": "🇹🇳", + "turkey": "🦃", + "turkmenistan": "🇹🇲", + "turks_caicos_islands": "🇹🇨", + "turtle": "🐢", + "tuvalu": "🇹🇻", + "tv": "📺", + "twisted_rightwards_arrows": "🔀", + "two": "2️⃣", + "two_hearts": "💕", + "two_men_holding_hands": "👬", + "two_women_holding_hands": "👭", + "u5272": "🈹", + "u5408": "🈴", + "u55b6": "🈺", + "u6307": "🈯", + "u6708": "🈷️", + "u6709": "🈶", + "u6e80": "🈵", + "u7121": "🈚", + "u7533": "🈸", + "u7981": "🈲", + "u7a7a": "🈳", + "uganda": "🇺🇬", + "uk": "🇬🇧", + "ukraine": "🇺🇦", + "umbrella": "☔", + "unamused": "😒", + "underage": "🔞", + "unicorn": "🦄", + "united_arab_emirates": "🇦🇪", + "united_nations": "🇺🇳", + "unlock": "🔓", + "up": "🆙", + "upside_down_face": "🙃", + "uruguay": "🇺🇾", + "us": "🇺🇸", + "us_outlying_islands": "🇺🇲", + "us_virgin_islands": "🇻🇮", + "uzbekistan": "🇺🇿", + "v": "✌️", + "vampire": "🧛", + "vampire_man": "🧛‍♂️", + "vampire_woman": "🧛‍♀️", + "vanuatu": "🇻🇺", + "vatican_city": "🇻🇦", + "venezuela": "🇻🇪", + "vertical_traffic_light": "🚦", + "vhs": "📼", + "vibration_mode": "📳", + "video_camera": "📹", + "video_game": "🎮", + "vietnam": "🇻🇳", + "violin": "🎻", + "virgo": "♍", + "volcano": "🌋", + "volleyball": "🏐", + "vomiting_face": "🤮", + "vs": "🆚", + "vulcan_salute": "🖖", + "waffle": "🧇", + "wales": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "walking": "🚶", + "walking_man": "🚶‍♂️", + "walking_woman": "🚶‍♀️", + "wallis_futuna": "🇼🇫", + "waning_crescent_moon": "🌘", + "waning_gibbous_moon": "🌖", + "warning": "⚠️", + "wastebasket": "🗑️", + "watch": "⌚", + "water_buffalo": "🐃", + "water_polo": "🤽", + "watermelon": "🍉", + "wave": "👋", + "wavy_dash": "〰️", + "waxing_crescent_moon": "🌒", + "waxing_gibbous_moon": "🌔", + "wc": "🚾", + "weary": "😩", + "wedding": "💒", + "weight_lifting": "🏋️", + "weight_lifting_man": "🏋️‍♂️", + "weight_lifting_woman": "🏋️‍♀️", + "western_sahara": "🇪🇭", + "whale": "🐳", + "whale2": "🐋", + "wheel_of_dharma": "☸️", + "wheelchair": "♿", + "white_check_mark": "✅", + "white_circle": "⚪", + "white_flag": "🏳️", + "white_flower": "💮", + "white_haired_man": "👨‍🦳", + "white_haired_woman": "👩‍🦳", + "white_heart": "🤍", + "white_large_square": "⬜", + "white_medium_small_square": "◽", + "white_medium_square": "◻️", + "white_small_square": "▫️", + "white_square_button": "🔳", + "wilted_flower": "🥀", + "wind_chime": "🎐", + "wind_face": "🌬️", + "window": "🪟", + "wine_glass": "🍷", + "wink": "😉", + "wolf": "🐺", + "woman": "👩", + "woman_artist": "👩‍🎨", + "woman_astronaut": "👩‍🚀", + "woman_beard": "🧔‍♀️", + "woman_cartwheeling": "🤸‍♀️", + "woman_cook": "👩‍🍳", + "woman_dancing": "💃", + "woman_facepalming": "🤦‍♀️", + "woman_factory_worker": "👩‍🏭", + "woman_farmer": "👩‍🌾", + "woman_feeding_baby": "👩‍🍼", + "woman_firefighter": "👩‍🚒", + "woman_health_worker": "👩‍⚕️", + "woman_in_manual_wheelchair": "👩‍🦽", + "woman_in_motorized_wheelchair": "👩‍🦼", + "woman_in_tuxedo": "🤵‍♀️", + "woman_judge": "👩‍⚖️", + "woman_juggling": "🤹‍♀️", + "woman_mechanic": "👩‍🔧", + "woman_office_worker": "👩‍💼", + "woman_pilot": "👩‍✈️", + "woman_playing_handball": "🤾‍♀️", + "woman_playing_water_polo": "🤽‍♀️", + "woman_scientist": "👩‍🔬", + "woman_shrugging": "🤷‍♀️", + "woman_singer": "👩‍🎤", + "woman_student": "👩‍🎓", + "woman_teacher": "👩‍🏫", + "woman_technologist": "👩‍💻", + "woman_with_headscarf": "🧕", + "woman_with_probing_cane": "👩‍🦯", + "woman_with_turban": "👳‍♀️", + "woman_with_veil": "👰‍♀️", + "womans_clothes": "👚", + "womans_hat": "👒", + "women_wrestling": "🤼‍♀️", + "womens": "🚺", + "wood": "🪵", + "woozy_face": "🥴", + "world_map": "🗺️", + "worm": "🪱", + "worried": "😟", + "wrench": "🔧", + "wrestling": "🤼", + "writing_hand": "✍️", + "x": "❌", + "yarn": "🧶", + "yawning_face": "🥱", + "yellow_circle": "🟡", + "yellow_heart": "💛", + "yellow_square": "🟨", + "yemen": "🇾🇪", + "yen": "💴", + "yin_yang": "☯️", + "yo_yo": "🪀", + "yum": "😋", + "zambia": "🇿🇲", + "zany_face": "🤪", + "zap": "⚡", + "zebra": "🦓", + "zero": "0️⃣", + "zimbabwe": "🇿🇼", + "zipper_mouth_face": "🤐", + "zombie": "🧟", + "zombie_man": "🧟‍♂️", + "zombie_woman": "🧟‍♀️", + "zzz": "💤" +} \ No newline at end of file diff --git a/server/message_cache.go b/server/message_cache.go index b55c34ba..fafd6d9b 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -5,15 +5,19 @@ import ( "encoding/json" "errors" "fmt" - _ "github.com/mattn/go-sqlite3" // SQLite driver - "heckel.io/ntfy/util" - "log" + "net/netip" "strings" "time" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + _ "github.com/mattn/go-sqlite3" // SQLite driver ) var ( errUnexpectedMessageType = errors.New("unexpected message type") + errMessageNotFound = errors.New("message not found") + errNoRows = errors.New("no rows found") ) // Messages cache @@ -24,73 +28,101 @@ const ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, time INT NOT NULL, + expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, title TEXT NOT NULL, priority INT NOT NULL, tags TEXT NOT NULL, click TEXT NOT NULL, + icon TEXT NOT NULL, actions TEXT NOT NULL, attachment_name TEXT NOT NULL, attachment_type TEXT NOT NULL, attachment_size INT NOT NULL, attachment_expires INT NOT NULL, attachment_url TEXT NOT NULL, - attachment_owner TEXT NOT NULL, + attachment_deleted INT NOT NULL, + sender TEXT NOT NULL, + user TEXT NOT NULL, + content_type TEXT NOT NULL, encoding TEXT NOT NULL, published INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); + CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); + CREATE INDEX IF NOT EXISTS idx_user ON messages (user); + CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); + CREATE TABLE IF NOT EXISTS stats ( + key TEXT PRIMARY KEY, + value INT + ); + INSERT INTO stats (key, value) VALUES ('messages', 0); COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` + updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` + selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics + selectMessagesByIDQuery = ` + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + FROM messages + WHERE mid = ? ` - pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` - selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?` selectMessagesSinceTimeQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesDueQuery = ` - SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding + SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id ` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` - selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` + selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` - selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?` - selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` + + updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` + selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` + selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` + selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` + + selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` + updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` ) // Schema management queries const ( - currentSchemaVersion = 6 + currentSchemaVersion = 12 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -173,37 +205,103 @@ const ( migrate5To6AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); ` + + // 6 -> 7 + migrate6To7AlterMessagesTableQuery = ` + ALTER TABLE messages RENAME COLUMN attachment_owner TO sender; + ` + + // 7 -> 8 + migrate7To8AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT(''); + ` + + // 8 -> 9 + migrate8To9AlterMessagesTableQuery = ` + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + ` + + // 9 -> 10 + migrate9To10AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0'); + CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); + CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); + CREATE INDEX IF NOT EXISTS idx_user ON messages (user); + CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); + ` + migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` + + // 10 -> 11 + migrate10To11AlterMessagesTableQuery = ` + CREATE TABLE IF NOT EXISTS stats ( + key TEXT PRIMARY KEY, + value INT + ); + INSERT INTO stats (key, value) VALUES ('messages', 0); + ` + + // 11 -> 12 + migrate11To12AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT(''); + ` +) + +var ( + migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{ + 0: migrateFrom0, + 1: migrateFrom1, + 2: migrateFrom2, + 3: migrateFrom3, + 4: migrateFrom4, + 5: migrateFrom5, + 6: migrateFrom6, + 7: migrateFrom7, + 8: migrateFrom8, + 9: migrateFrom9, + 10: migrateFrom10, + 11: migrateFrom11, + } ) type messageCache struct { - db *sql.DB - nop bool + db *sql.DB + queue *util.BatchingQueue[*message] + nop bool } // newSqliteCache creates a SQLite file-backed cache -func newSqliteCache(filename string, nop bool) (*messageCache, error) { +func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) { db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err } - if err := setupCacheDB(db); err != nil { + if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil { return nil, err } - return &messageCache{ - db: db, - nop: nop, - }, nil + var queue *util.BatchingQueue[*message] + if batchSize > 0 || batchTimeout > 0 { + queue = util.NewBatchingQueue[*message](batchSize, batchTimeout) + } + cache := &messageCache{ + db: db, + queue: queue, + nop: nop, + } + go cache.processMessageBatches() + return cache, nil } // newMemCache creates an in-memory cache func newMemCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), false) + return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, false) } // newNopCache creates an in-memory cache that discards all messages; // it is always empty and can be used if caching is entirely disabled func newNopCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), true) + return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, true) } // createMemoryFilename creates a unique memory filename to use for the SQLite backend. @@ -216,54 +314,97 @@ func createMemoryFilename() string { return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10)) } +// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously. +// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor. func (c *messageCache) AddMessage(m *message) error { - if m.Event != messageEvent { - return errUnexpectedMessageType + if c.queue != nil { + c.queue.Enqueue(m) + return nil } + return c.addMessages([]*message{m}) +} + +// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until +// SQLite's busy_timeout is exceeded before erroring out. +func (c *messageCache) addMessages(ms []*message) error { if c.nop { return nil } - published := m.Time <= time.Now().Unix() - tags := strings.Join(m.Tags, ",") - var attachmentName, attachmentType, attachmentURL, attachmentOwner string - var attachmentSize, attachmentExpires int64 - if m.Attachment != nil { - attachmentName = m.Attachment.Name - attachmentType = m.Attachment.Type - attachmentSize = m.Attachment.Size - attachmentExpires = m.Attachment.Expires - attachmentURL = m.Attachment.URL - attachmentOwner = m.Attachment.Owner + if len(ms) == 0 { + return nil } - var actionsStr string - if len(m.Actions) > 0 { - actionsBytes, err := json.Marshal(m.Actions) + start := time.Now() + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Prepare(insertMessageQuery) + if err != nil { + return err + } + defer stmt.Close() + for _, m := range ms { + if m.Event != messageEvent { + return errUnexpectedMessageType + } + published := m.Time <= time.Now().Unix() + tags := strings.Join(m.Tags, ",") + var attachmentName, attachmentType, attachmentURL string + var attachmentSize, attachmentExpires, attachmentDeleted int64 + if m.Attachment != nil { + attachmentName = m.Attachment.Name + attachmentType = m.Attachment.Type + attachmentSize = m.Attachment.Size + attachmentExpires = m.Attachment.Expires + attachmentURL = m.Attachment.URL + } + var actionsStr string + if len(m.Actions) > 0 { + actionsBytes, err := json.Marshal(m.Actions) + if err != nil { + return err + } + actionsStr = string(actionsBytes) + } + var sender string + if m.Sender.IsValid() { + sender = m.Sender.String() + } + _, err := stmt.Exec( + m.ID, + m.Time, + m.Expires, + m.Topic, + m.Message, + m.Title, + m.Priority, + tags, + m.Click, + m.Icon, + actionsStr, + attachmentName, + attachmentType, + attachmentSize, + attachmentExpires, + attachmentURL, + attachmentDeleted, // Always zero + sender, + m.User, + m.ContentType, + m.Encoding, + published, + ) if err != nil { return err } - actionsStr = string(actionsBytes) } - _, err := c.db.Exec( - insertMessageQuery, - m.ID, - m.Time, - m.Topic, - m.Message, - m.Title, - m.Priority, - tags, - m.Click, - actionsStr, - attachmentName, - attachmentType, - attachmentSize, - attachmentExpires, - attachmentURL, - attachmentOwner, - m.Encoding, - published, - ) - return err + if err := tx.Commit(); err != nil { + log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start)) + return err + } + log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start)) + return nil } func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) { @@ -290,7 +431,7 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu } func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) { - idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID()) + idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID()) if err != nil { return nil, err } @@ -323,27 +464,62 @@ func (c *messageCache) MessagesDue() ([]*message, error) { return readMessages(rows) } +// MessagesExpired returns a list of IDs for messages that have expires (should be deleted) +func (c *messageCache) MessagesExpired() ([]string, error) { + rows, err := c.db.Query(selectMessagesExpiredQuery, time.Now().Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (c *messageCache) Message(id string) (*message, error) { + rows, err := c.db.Query(selectMessagesByIDQuery, id) + if err != nil { + return nil, err + } + if !rows.Next() { + return nil, errMessageNotFound + } + defer rows.Close() + return readMessage(rows) +} + func (c *messageCache) MarkPublished(m *message) error { _, err := c.db.Exec(updateMessagePublishedQuery, m.ID) return err } -func (c *messageCache) MessageCount(topic string) (int, error) { - rows, err := c.db.Query(selectMessageCountForTopicQuery, topic) +func (c *messageCache) MessageCounts() (map[string]int, error) { + rows, err := c.db.Query(selectMessageCountPerTopicQuery) if err != nil { - return 0, err + return nil, err } defer rows.Close() + var topic string var count int - if !rows.Next() { - return 0, errors.New("no rows found") + counts := make(map[string]int) + for rows.Next() { + if err := rows.Scan(&topic, &count); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + counts[topic] = count } - if err := rows.Scan(&count); err != nil { - return 0, err - } else if err := rows.Err(); err != nil { - return 0, err - } - return count, nil + return counts, nil } func (c *messageCache) Topics() (map[string]*topic, error) { @@ -366,27 +542,32 @@ func (c *messageCache) Topics() (map[string]*topic, error) { return topics, nil } -func (c *messageCache) Prune(olderThan time.Time) error { - _, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix()) - return err +func (c *messageCache) DeleteMessages(ids ...string) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(deleteMessageQuery, id); err != nil { + return err + } + } + return tx.Commit() } -func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) { - rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix()) +func (c *messageCache) ExpireMessages(topics ...string) error { + tx, err := c.db.Begin() if err != nil { - return 0, err + return err } - defer rows.Close() - var size int64 - if !rows.Next() { - return 0, errors.New("no rows found") + defer tx.Rollback() + for _, t := range topics { + if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil { + return err + } } - if err := rows.Scan(&size); err != nil { - return 0, err - } else if err := rows.Err(); err != nil { - return 0, err - } - return size, nil + return tx.Commit() } func (c *messageCache) AttachmentsExpired() ([]string, error) { @@ -409,69 +590,70 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) { return ids, nil } +func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(updateAttachmentDeleted, id); err != nil { + return err + } + } + return tx.Commit() +} + +func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error) { + rows, err := c.db.Query(selectAttachmentsSizeBySenderQuery, sender, time.Now().Unix()) + if err != nil { + return 0, err + } + return c.readAttachmentBytesUsed(rows) +} + +func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) { + rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix()) + if err != nil { + return 0, err + } + return c.readAttachmentBytesUsed(rows) +} + +func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { + defer rows.Close() + var size int64 + if !rows.Next() { + return 0, errors.New("no rows found") + } + if err := rows.Scan(&size); err != nil { + return 0, err + } else if err := rows.Err(); err != nil { + return 0, err + } + return size, nil +} + +func (c *messageCache) processMessageBatches() { + if c.queue == nil { + return + } + for messages := range c.queue.Dequeue() { + if err := c.addMessages(messages); err != nil { + log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch") + } + } +} + func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) for rows.Next() { - var timestamp, attachmentSize, attachmentExpires int64 - var priority int - var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string - err := rows.Scan( - &id, - ×tamp, - &topic, - &msg, - &title, - &priority, - &tagsStr, - &click, - &actionsStr, - &attachmentName, - &attachmentType, - &attachmentSize, - &attachmentExpires, - &attachmentURL, - &attachmentOwner, - &encoding, - ) + m, err := readMessage(rows) if err != nil { return nil, err } - var tags []string - if tagsStr != "" { - tags = strings.Split(tagsStr, ",") - } - var actions []*action - if actionsStr != "" { - if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { - return nil, err - } - } - var att *attachment - if attachmentName != "" && attachmentURL != "" { - att = &attachment{ - Name: attachmentName, - Type: attachmentType, - Size: attachmentSize, - Expires: attachmentExpires, - URL: attachmentURL, - Owner: attachmentOwner, - } - } - messages = append(messages, &message{ - ID: id, - Time: timestamp, - Event: messageEvent, - Topic: topic, - Message: msg, - Title: title, - Priority: priority, - Tags: tags, - Click: click, - Actions: actions, - Attachment: att, - Encoding: encoding, - }) + messages = append(messages, m) } if err := rows.Err(); err != nil { return nil, err @@ -479,7 +661,112 @@ func readMessages(rows *sql.Rows) ([]*message, error) { return messages, nil } -func setupCacheDB(db *sql.DB) error { +func readMessage(rows *sql.Rows) (*message, error) { + var timestamp, expires, attachmentSize, attachmentExpires int64 + var priority int + var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + err := rows.Scan( + &id, + ×tamp, + &expires, + &topic, + &msg, + &title, + &priority, + &tagsStr, + &click, + &icon, + &actionsStr, + &attachmentName, + &attachmentType, + &attachmentSize, + &attachmentExpires, + &attachmentURL, + &sender, + &user, + &contentType, + &encoding, + ) + if err != nil { + return nil, err + } + var tags []string + if tagsStr != "" { + tags = strings.Split(tagsStr, ",") + } + var actions []*action + if actionsStr != "" { + if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { + return nil, err + } + } + senderIP, err := netip.ParseAddr(sender) + if err != nil { + senderIP = netip.Addr{} // if no IP stored in database, return invalid address + } + var att *attachment + if attachmentName != "" && attachmentURL != "" { + att = &attachment{ + Name: attachmentName, + Type: attachmentType, + Size: attachmentSize, + Expires: attachmentExpires, + URL: attachmentURL, + } + } + return &message{ + ID: id, + Time: timestamp, + Expires: expires, + Event: messageEvent, + Topic: topic, + Message: msg, + Title: title, + Priority: priority, + Tags: tags, + Click: click, + Icon: icon, + Actions: actions, + Attachment: att, + Sender: senderIP, // Must parse assuming database must be correct + User: user, + ContentType: contentType, + Encoding: encoding, + }, nil +} + +func (c *messageCache) UpdateStats(messages int64) error { + _, err := c.db.Exec(updateStatsQuery, messages) + return err +} + +func (c *messageCache) Stats() (messages int64, err error) { + rows, err := c.db.Query(selectStatsQuery) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + if err := rows.Scan(&messages); err != nil { + return 0, err + } + return messages, nil +} + +func (c *messageCache) Close() error { + return c.db.Close() +} + +func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error { + // Run startup queries + if startupQueries != "" { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + } + // If 'messages' table does not exist, this must be a new database rowsMC, err := db.Query(selectMessagesCountQuery) if err != nil { @@ -504,20 +791,18 @@ func setupCacheDB(db *sql.DB) error { // Do migrations if schemaVersion == currentSchemaVersion { return nil - } else if schemaVersion == 0 { - return migrateFrom0(db) - } else if schemaVersion == 1 { - return migrateFrom1(db) - } else if schemaVersion == 2 { - return migrateFrom2(db) - } else if schemaVersion == 3 { - return migrateFrom3(db) - } else if schemaVersion == 4 { - return migrateFrom4(db) - } else if schemaVersion == 5 { - return migrateFrom5(db) + } else if schemaVersion > currentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) } - return fmt.Errorf("unexpected schema version found: %d", schemaVersion) + for i := schemaVersion; i < currentSchemaVersion; i++ { + fn, ok := migrations[i] + if !ok { + return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1) + } else if err := fn(db, cacheDuration); err != nil { + return err + } + } + return nil } func setupNewCacheDB(db *sql.DB) error { @@ -533,8 +818,8 @@ func setupNewCacheDB(db *sql.DB) error { return nil } -func migrateFrom0(db *sql.DB) error { - log.Print("Migrating cache database schema: from 0 to 1") +func migrateFrom0(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1") if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { return err } @@ -544,60 +829,144 @@ func migrateFrom0(db *sql.DB) error { if _, err := db.Exec(insertSchemaVersion, 1); err != nil { return err } - return migrateFrom1(db) + return nil } -func migrateFrom1(db *sql.DB) error { - log.Print("Migrating cache database schema: from 1 to 2") +func migrateFrom1(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2") if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { return err } if _, err := db.Exec(updateSchemaVersion, 2); err != nil { return err } - return migrateFrom2(db) + return nil } -func migrateFrom2(db *sql.DB) error { - log.Print("Migrating cache database schema: from 2 to 3") +func migrateFrom2(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3") if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { return err } if _, err := db.Exec(updateSchemaVersion, 3); err != nil { return err } - return migrateFrom3(db) + return nil } -func migrateFrom3(db *sql.DB) error { - log.Print("Migrating cache database schema: from 3 to 4") +func migrateFrom3(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4") if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { return err } if _, err := db.Exec(updateSchemaVersion, 4); err != nil { return err } - return migrateFrom4(db) + return nil } -func migrateFrom4(db *sql.DB) error { - log.Print("Migrating cache database schema: from 4 to 5") +func migrateFrom4(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5") if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { return err } if _, err := db.Exec(updateSchemaVersion, 5); err != nil { return err } - return migrateFrom5(db) + return nil } -func migrateFrom5(db *sql.DB) error { - log.Print("Migrating cache database schema: from 5 to 6") +func migrateFrom5(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6") if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { return err } if _, err := db.Exec(updateSchemaVersion, 6); err != nil { return err } - return nil // Update this when a new version is added + return nil +} + +func migrateFrom6(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7") + if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(updateSchemaVersion, 7); err != nil { + return err + } + return nil +} + +func migrateFrom7(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8") + if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(updateSchemaVersion, 8); err != nil { + return err + } + return nil +} + +func migrateFrom8(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9") + if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(updateSchemaVersion, 9); err != nil { + return err + } + return nil +} + +func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate9To10AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 10); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom10(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 11); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom11(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate11To12AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 12); err != nil { + return err + } + return tx.Commit() } diff --git a/server/message_cache_test.go b/server/message_cache_test.go index cb888b42..79b7fc54 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -3,11 +3,13 @@ package server import ( "database/sql" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "net/netip" "path/filepath" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSqliteCache_Messages(t *testing.T) { @@ -34,9 +36,9 @@ func testCacheMessages(t *testing.T, c *messageCache) { require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added! // mytopic: count - count, err := c.MessageCount("mytopic") + counts, err := c.MessageCounts() require.Nil(t, err) - require.Equal(t, 2, count) + require.Equal(t, 2, counts["mytopic"]) // mytopic: since all messages, _ := c.Messages("mytopic", sinceAllMessages, false) @@ -66,18 +68,18 @@ func testCacheMessages(t *testing.T, c *messageCache) { require.Equal(t, "my other message", messages[0].Message) // example: count - count, err = c.MessageCount("example") + counts, err = c.MessageCounts() require.Nil(t, err) - require.Equal(t, 1, count) + require.Equal(t, 1, counts["example"]) // example: since all messages, _ = c.Messages("example", sinceAllMessages, false) require.Equal(t, "my example message", messages[0].Message) // non-existing: count - count, err = c.MessageCount("doesnotexist") + counts, err = c.MessageCounts() require.Nil(t, err) - require.Equal(t, 0, count) + require.Equal(t, 0, counts["doesnotexist"]) // non-existing: since all messages, _ = c.Messages("doesnotexist", sinceAllMessages, false) @@ -241,27 +243,37 @@ func TestMemCache_Prune(t *testing.T) { } func testCachePrune(t *testing.T, c *messageCache) { + now := time.Now().Unix() + m1 := newDefaultMessage("mytopic", "my message") - m1.Time = 1 + m1.Time = now - 10 + m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") - m2.Time = 2 + m2.Time = now - 5 + m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") - m3.Time = 1 + m3.Time = now - 12 + m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) - require.Nil(t, c.Prune(time.Unix(2, 0))) - count, err := c.MessageCount("mytopic") + counts, err := c.MessageCounts() require.Nil(t, err) - require.Equal(t, 1, count) + require.Equal(t, 2, counts["mytopic"]) + require.Equal(t, 1, counts["another_topic"]) - count, err = c.MessageCount("another_topic") + expiredMessageIDs, err := c.MessagesExpired() require.Nil(t, err) - require.Equal(t, 0, count) + require.Nil(t, c.DeleteMessages(expiredMessageIDs...)) + + counts, err = c.MessageCounts() + require.Nil(t, err) + require.Equal(t, 1, counts["mytopic"]) + require.Equal(t, 0, counts["another_topic"]) messages, err := c.Messages("mytopic", sinceAllMessages, false) require.Nil(t, err) @@ -278,42 +290,43 @@ func TestMemCache_Attachments(t *testing.T) { } func testCacheAttachments(t *testing.T, c *messageCache) { - expires1 := time.Now().Add(-4 * time.Hour).Unix() + expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" + m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "flower.jpg", Type: "image/jpeg", Size: 5000, Expires: expires1, URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", - Owner: "1.2.3.4", } require.Nil(t, c.AddMessage(m)) expires2 := time.Now().Add(2 * time.Hour).Unix() // Future m = newDefaultMessage("mytopic", "sending you a car") m.ID = "m2" + m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "car.jpg", Type: "image/jpeg", Size: 10000, Expires: expires2, URL: "https://ntfy.sh/file/aCaRURL.jpg", - Owner: "1.2.3.4", } require.Nil(t, c.AddMessage(m)) expires3 := time.Now().Add(1 * time.Hour).Unix() // Future m = newDefaultMessage("another-topic", "sending you another car") m.ID = "m3" + m.User = "u_BAsbaAa" + m.Sender = netip.MustParseAddr("5.6.7.8") m.Attachment = &attachment{ Name: "another-car.jpg", Type: "image/jpeg", Size: 20000, Expires: expires3, URL: "https://ntfy.sh/file/zakaDHFW.jpg", - Owner: "1.2.3.4", } require.Nil(t, c.AddMessage(m)) @@ -327,7 +340,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { require.Equal(t, int64(5000), messages[0].Attachment.Size) require.Equal(t, expires1, messages[0].Attachment.Expires) require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner) + require.Equal(t, "1.2.3.4", messages[0].Sender.String()) require.Equal(t, "sending you a car", messages[1].Message) require.Equal(t, "car.jpg", messages[1].Attachment.Name) @@ -335,19 +348,74 @@ func testCacheAttachments(t *testing.T, c *messageCache) { require.Equal(t, int64(10000), messages[1].Attachment.Size) require.Equal(t, expires2, messages[1].Attachment.Expires) require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner) + require.Equal(t, "1.2.3.4", messages[1].Sender.String()) - size, err := c.AttachmentBytesUsed("1.2.3.4") + size, err := c.AttachmentBytesUsedBySender("1.2.3.4") require.Nil(t, err) - require.Equal(t, int64(30000), size) + require.Equal(t, int64(10000), size) - size, err = c.AttachmentBytesUsed("5.6.7.8") + size, err = c.AttachmentBytesUsedBySender("5.6.7.8") require.Nil(t, err) - require.Equal(t, int64(0), size) + require.Equal(t, int64(0), size) // Accounted to the user, not the IP! + + size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa") + require.Nil(t, err) + require.Equal(t, int64(20000), size) +} + +func TestSqliteCache_Attachments_Expired(t *testing.T) { + testCacheAttachmentsExpired(t, newSqliteTestCache(t)) +} + +func TestMemCache_Attachments_Expired(t *testing.T) { + testCacheAttachmentsExpired(t, newMemTestCache(t)) +} + +func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { + m := newDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, c.AddMessage(m)) + + m = newDefaultMessage("mytopic", "message with attachment") + m.ID = "m2" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: time.Now().Add(2 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, c.AddMessage(m)) + + m = newDefaultMessage("mytopic", "message with external attachment") + m.ID = "m3" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Expires: 0, // Unknown! + URL: "https://somedomain.com/car.jpg", + } + require.Nil(t, c.AddMessage(m)) + + m = newDefaultMessage("mytopic2", "message with expired attachment") + m.ID = "m4" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &attachment{ + Name: "expired-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: time.Now().Add(-1 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, c.AddMessage(m)) ids, err := c.AttachmentsExpired() require.Nil(t, err) - require.Equal(t, []string{"m1"}, ids) + require.Equal(t, 1, len(ids)) + require.Equal(t, "m4", ids[0]) } func TestSqliteCache_Migration_From0(t *testing.T) { @@ -378,7 +446,7 @@ func TestSqliteCache_Migration_From0(t *testing.T) { require.Nil(t, db.Close()) // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename) + c := newSqliteTestCacheFromFile(t, filename, "") checkSchemaVersion(t, c.db) messages, err := c.Messages("mytopic", sinceAllMessages, false) @@ -424,7 +492,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) { require.Nil(t, db.Close()) // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename) + c := newSqliteTestCacheFromFile(t, filename, "") checkSchemaVersion(t, c.db) // Add delayed message @@ -443,6 +511,157 @@ func TestSqliteCache_Migration_From1(t *testing.T) { require.Equal(t, 11, len(messages)) } +func TestSqliteCache_Migration_From9(t *testing.T) { + // This primarily tests the awkward migration that introduces the "expires" column. + // The migration logic has to update the column, using the existing "cache-duration" value. + + filename := newSqliteTestCacheFile(t) + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 8" schema + _, err = db.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mid TEXT NOT NULL, + time INT NOT NULL, + topic TEXT NOT NULL, + message TEXT NOT NULL, + title TEXT NOT NULL, + priority INT NOT NULL, + tags TEXT NOT NULL, + click TEXT NOT NULL, + icon TEXT NOT NULL, + actions TEXT NOT NULL, + attachment_name TEXT NOT NULL, + attachment_type TEXT NOT NULL, + attachment_size INT NOT NULL, + attachment_expires INT NOT NULL, + attachment_url TEXT NOT NULL, + sender TEXT NOT NULL, + encoding TEXT NOT NULL, + published INT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schemaVersion (id, version) VALUES (1, 9); + COMMIT; + `) + require.Nil(t, err) + + // Insert a bunch of messages + insertQuery := ` + INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + for i := 0; i < 10; i++ { + _, err = db.Exec( + insertQuery, + fmt.Sprintf("abcd%d", i), + time.Now().Unix(), + "mytopic", + fmt.Sprintf("some message %d", i), + "", // title + 0, // priority + "", // tags + "", // click + "", // icon + "", // actions + "", // attachment_name + "", // attachment_type + 0, // attachment_size + 0, // attachment_type + "", // attachment_url + "9.9.9.9", // sender + "", // encoding + 1, // published + ) + require.Nil(t, err) + } + + // Create cache to trigger migration + cacheDuration := 17 * time.Hour + c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false) + require.Nil(t, err) + checkSchemaVersion(t, c.db) + + // Check version + rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`) + require.Nil(t, err) + require.True(t, rows.Next()) + var version int + require.Nil(t, rows.Scan(&version)) + require.Equal(t, currentSchemaVersion, version) + + messages, err := c.Messages("mytopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 10, len(messages)) + for _, m := range messages { + require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix()) + require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix()) + } +} + +func TestSqliteCache_StartupQueries_WAL(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := `pragma journal_mode = WAL; +pragma synchronous = normal; +pragma temp_store = memory;` + db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) + require.Nil(t, err) + require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.FileExists(t, filename+"-wal") + require.FileExists(t, filename+"-shm") +} + +func TestSqliteCache_StartupQueries_None(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := "" + db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) + require.Nil(t, err) + require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.NoFileExists(t, filename+"-wal") + require.NoFileExists(t, filename+"-shm") +} + +func TestSqliteCache_StartupQueries_Fail(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := `xx error` + _, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) + require.Error(t, err) +} + +func TestSqliteCache_Sender(t *testing.T) { + testSender(t, newSqliteTestCache(t)) +} + +func TestMemCache_Sender(t *testing.T) { + testSender(t, newMemTestCache(t)) +} + +func testSender(t *testing.T, c *messageCache) { + m1 := newDefaultMessage("mytopic", "mymessage") + m1.Sender = netip.MustParseAddr("1.2.3.4") + require.Nil(t, c.AddMessage(m1)) + + m2 := newDefaultMessage("mytopic", "mymessage without sender") + require.Nil(t, c.AddMessage(m2)) + + messages, err := c.Messages("mytopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4")) + require.Equal(t, messages[1].Sender, netip.Addr{}) +} + func checkSchemaVersion(t *testing.T, db *sql.DB) { rows, err := db.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) @@ -468,7 +687,7 @@ func TestMemCache_NopCache(t *testing.T) { } func newSqliteTestCache(t *testing.T) *messageCache { - c, err := newSqliteCache(newSqliteTestCacheFile(t), false) + c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false) if err != nil { t.Fatal(err) } @@ -479,8 +698,8 @@ func newSqliteTestCacheFile(t *testing.T) string { return filepath.Join(t.TempDir(), "cache.db") } -func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache { - c, err := newSqliteCache(filename, false) +func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache { + c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) if err != nil { t.Fatal(err) } diff --git a/server/ntfy.service b/server/ntfy.service index 6645b21f..8bf250a5 100644 --- a/server/ntfy.service +++ b/server/ntfy.service @@ -5,7 +5,8 @@ After=network.target [Service] User=ntfy Group=ntfy -ExecStart=/usr/bin/ntfy serve +ExecStart=/usr/bin/ntfy serve --no-log-dates +ExecReload=/bin/kill --signal HUP $MAINPID Restart=on-failure AmbientCapabilities=CAP_NET_BIND_SERVICE LimitNOFILE=10000 diff --git a/server/server.go b/server/server.go index 4b40db45..8610f443 100644 --- a/server/server.go +++ b/server/server.go @@ -3,51 +3,63 @@ package server import ( "bytes" "context" + "crypto/sha256" "embed" "encoding/base64" "encoding/json" "errors" "fmt" - "github.com/emersion/go-smtp" - "github.com/gorilla/websocket" - "golang.org/x/sync/errgroup" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" "io" - "log" "net" "net/http" - "net/http/httptest" + "net/http/pprof" + "net/netip" "net/url" "os" "path" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" "time" "unicode/utf8" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/emersion/go-smtp" + "github.com/gorilla/websocket" + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/sync/errgroup" ) // Server is the main server, providing the UI and API for ntfy type Server struct { - config *Config - httpServer *http.Server - httpsServer *http.Server - unixListener net.Listener - smtpServer *smtp.Server - smtpBackend *smtpBackend - topics map[string]*topic - visitors map[string]*visitor - firebase subscriber - mailer mailer - messages int64 - auth auth.Auther - messageCache *messageCache - fileCache *fileCache - closeChan chan bool - mu sync.Mutex + config *Config + httpServer *http.Server + httpsServer *http.Server + httpMetricsServer *http.Server + httpProfileServer *http.Server + unixListener net.Listener + smtpServer *smtp.Server + smtpServerBackend *smtpBackend + smtpSender mailer + topics map[string]*topic + visitors map[string]*visitor // ip: or user: + firebaseClient *firebaseClient + messages int64 // Total number of messages (persisted if messageCache enabled) + messagesHistory []int64 // Last n values of the messages counter, used to determine rate + userManager *user.Manager // Might be nil! + messageCache *messageCache // Database that stores the messages + webPush *webPushStore // Database that stores web push subscriptions + fileCache *fileCache // File system based cache that stores attachments + stripe stripeAPI // Stripe API, can be replaced with a mock + priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) + metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set + closeChan chan bool + mu sync.RWMutex } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -65,23 +77,44 @@ var ( authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - webConfigPath = "/config.js" - userStatsPath = "/user/stats" - staticRegex = regexp.MustCompile(`^/static/.+`) - docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) - fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) - disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app - attachURLRegex = regexp.MustCompile(`^https?://`) - - //go:embed "example.html" - exampleSource string + webConfigPath = "/config.js" + webManifestPath = "/manifest.webmanifest" + webRootHTMLPath = "/app.html" + webServiceWorkerPath = "/sw.js" + accountPath = "/account" + matrixPushPath = "/_matrix/push/v1/notify" + metricsPath = "/metrics" + apiHealthPath = "/v1/health" + apiStatsPath = "/v1/stats" + apiWebPushPath = "/v1/webpush" + apiTiersPath = "/v1/tiers" + apiUsersPath = "/v1/users" + apiUsersAccessPath = "/v1/users/access" + apiAccountPath = "/v1/account" + apiAccountTokenPath = "/v1/account/token" + apiAccountPasswordPath = "/v1/account/password" + apiAccountSettingsPath = "/v1/account/settings" + apiAccountSubscriptionPath = "/v1/account/subscription" + apiAccountReservationPath = "/v1/account/reservation" + apiAccountPhonePath = "/v1/account/phone" + apiAccountPhoneVerifyPath = "/v1/account/phone/verify" + apiAccountBillingPortalPath = "/v1/account/billing/portal" + apiAccountBillingWebhookPath = "/v1/account/billing/webhook" + apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" + apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}" + apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`) + apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`) + staticRegex = regexp.MustCompile(`^/static/.+`) + docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) + fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) + urlRegex = regexp.MustCompile(`^https?://`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site - webFs embed.FS - webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs} - webSiteDir = "/site" - webHomeIndex = "/home.html" // Landing page, only if "web-root: home" - webAppIndex = "/app.html" // React app + webFs embed.FS + webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs} + webSiteDir = "/site" + webAppIndex = "/app.html" // React app //go:embed docs docsStaticFs embed.FS @@ -90,9 +123,15 @@ var ( const ( firebaseControlTopic = "~control" // See Android if changed + firebasePollTopic = "~poll" // See iOS if changed emptyMessageBody = "triggered" // Used if message body is empty + newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment - encodingBase64 = "base64" + encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages + jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body + unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber + unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part + messagesHistoryMax = 10 // Number of message count values to keep in memory ) // WebSocket constants @@ -110,53 +149,80 @@ func New(conf *Config) (*Server, error) { if conf.SMTPSenderAddr != "" { mailer = &smtpSender{config: conf} } + var stripe stripeAPI + if conf.StripeSecretKey != "" { + stripe = newStripeAPI() + } messageCache, err := createMessageCache(conf) if err != nil { return nil, err } + var webPush *webPushStore + if conf.WebPushPublicKey != "" { + webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries) + if err != nil { + return nil, err + } + } topics, err := messageCache.Topics() if err != nil { return nil, err } + messages, err := messageCache.Stats() + if err != nil { + return nil, err + } var fileCache *fileCache if conf.AttachmentCacheDir != "" { - fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentFileSizeLimit) + fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit) if err != nil { return nil, err } } - var auther auth.Auther + var userManager *user.Manager if conf.AuthFile != "" { - auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite) + userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval) if err != nil { return nil, err } } - var firebaseSubscriber subscriber + var firebaseClient *firebaseClient if conf.FirebaseKeyFile != "" { - var err error - firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther) + sender, err := newFirebaseSender(conf.FirebaseKeyFile) if err != nil { return nil, err } + // This awkward logic is required because Go is weird about nil types and interfaces. + // See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example + var auther user.Auther + if userManager != nil { + auther = userManager + } + firebaseClient = newFirebaseClient(sender, auther) } - return &Server{ - config: conf, - messageCache: messageCache, - fileCache: fileCache, - firebase: firebaseSubscriber, - mailer: mailer, - topics: topics, - auth: auther, - visitors: make(map[string]*visitor), - }, nil + s := &Server{ + config: conf, + messageCache: messageCache, + webPush: webPush, + fileCache: fileCache, + firebaseClient: firebaseClient, + smtpSender: mailer, + topics: topics, + userManager: userManager, + messages: messages, + messagesHistory: []int64{messages}, + visitors: make(map[string]*visitor), + stripe: stripe, + } + s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) + return s, nil } func createMessageCache(conf *Config) (*messageCache, error) { if conf.CacheDuration == 0 { return newNopCache() } else if conf.CacheFile != "" { - return newSqliteCache(conf.CacheFile, false) + return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false) } return newMemCache() } @@ -177,7 +243,17 @@ func (s *Server) Run() error { if s.config.SMTPServerListen != "" { listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) } - log.Printf("Listening on%s", listenStr) + if s.config.MetricsListenHTTP != "" { + listenStr += fmt.Sprintf(" %s[http/metrics]", s.config.MetricsListenHTTP) + } + if s.config.ProfileListenHTTP != "" { + listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP) + } + log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) + if log.IsFile() { + fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) + fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File()) + } mux := http.NewServeMux() mux.HandleFunc("/", s.handle) errChan := make(chan error) @@ -202,14 +278,45 @@ func (s *Server) Run() error { os.Remove(s.config.ListenUnix) s.unixListener, err = net.Listen("unix", s.config.ListenUnix) if err != nil { + s.mu.Unlock() errChan <- err return } + defer s.unixListener.Close() + if s.config.ListenUnixMode > 0 { + if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil { + s.mu.Unlock() + errChan <- err + return + } + } s.mu.Unlock() httpServer := &http.Server{Handler: mux} errChan <- httpServer.Serve(s.unixListener) }() } + if s.config.MetricsListenHTTP != "" { + initMetrics() + s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()} + go func() { + errChan <- s.httpMetricsServer.ListenAndServe() + }() + } else if s.config.EnableMetrics { + initMetrics() + s.metricsHandler = promhttp.Handler() + } + if s.config.ProfileListenHTTP != "" { + profileMux := http.NewServeMux() + profileMux.HandleFunc("/debug/pprof/", pprof.Index) + profileMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + profileMux.HandleFunc("/debug/pprof/profile", pprof.Profile) + profileMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + profileMux.HandleFunc("/debug/pprof/trace", pprof.Trace) + s.httpProfileServer = &http.Server{Addr: s.config.ProfileListenHTTP, Handler: profileMux} + go func() { + errChan <- s.httpProfileServer.ListenAndServe() + }() + } if s.config.SMTPServerListen != "" { go func() { errChan <- s.runSMTPServer() @@ -217,7 +324,8 @@ func (s *Server) Run() error { } s.mu.Unlock() go s.runManager() - go s.runAtSender() + go s.runStatsResetter() + go s.runDelayedSender() go s.runFirebaseKeepaliver() return <-errChan @@ -239,88 +347,207 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } + s.closeDatabases() close(s.closeChan) } -func (s *Server) handle(w http.ResponseWriter, r *http.Request) { - v := s.visitor(r) - if err := s.handleInternal(w, r, v); err != nil { - if websocket.IsWebSocketUpgrade(r) { - log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error()) - return // Do not attempt to write to upgraded connection - } - httpErr, ok := err.(*errHTTP) - if !ok { - httpErr = errHTTPInternalError - } - log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error()) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - w.WriteHeader(httpErr.HTTPCode) - io.WriteString(w, httpErr.JSON()+"\n") +func (s *Server) closeDatabases() { + if s.userManager != nil { + s.userManager.Close() + } + s.messageCache.Close() + if s.webPush != nil { + s.webPush.Close() } } +// handle is the main entry point for all HTTP requests +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned + if err != nil { + s.handleError(w, r, v, err) + return + } + ev := logvr(v, r) + if ev.IsTrace() { + ev.Field("http_request", renderHTTPRequest(r)).Trace("HTTP request started") + } else if logvr(v, r).IsDebug() { + ev.Debug("HTTP request started") + } + logvr(v, r). + Timing(func() { + if err := s.handleInternal(w, r, v); err != nil { + s.handleError(w, r, v, err) + return + } + if metricHTTPRequests != nil { + metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc() + } + }). + Debug("HTTP request finished") +} + +func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, err error) { + httpErr, ok := err.(*errHTTP) + if !ok { + httpErr = errHTTPInternalError + } + if metricHTTPRequests != nil { + metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc() + } + isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode) + isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode) + ev := logvr(v, r).Err(err) + if websocket.IsWebSocketUpgrade(r) { + ev.Tag(tagWebsocket).Fields(websocketErrorContext(err)) + if isNormalError { + ev.Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error()) + } else { + ev.Info("WebSocket error: %s", err.Error()) + } + return // Do not attempt to write to upgraded connection + } + if isNormalError { + ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) + } else { + ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) + } + if isRateLimiting && s.config.StripeSecretKey != "" { + u := v.User() + if u == nil || u.Tier == nil { + httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL) + } + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests + w.WriteHeader(httpErr.HTTPCode) + io.WriteString(w, httpErr.JSON()+"\n") +} + func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { - if r.Method == http.MethodGet && r.URL.Path == "/" { - return s.handleHome(w, r) - } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { - return s.handleExample(w, r) + if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { + return s.ensureWebEnabled(s.handleRoot)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { - return s.handleEmpty(w, r, v) + return s.ensureWebEnabled(s.handleEmpty)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { + return s.handleHealth(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { - return s.handleWebConfig(w, r) - } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { - return s.handleUserStats(w, r, v) - } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { - return s.handleStatic(w, r) + return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { + return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath { + return s.ensureAdmin(s.handleUsersGet)(w, r, v) + } else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath { + return s.ensureAdmin(s.handleUsersAdd)(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath { + return s.ensureAdmin(s.handleUsersDelete)(w, r, v) + } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath { + return s.ensureAdmin(s.handleAccessAllow)(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiUsersAccessPath { + return s.ensureAdmin(s.handleAccessReset)(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath { + return s.ensureUserManager(s.handleAccountCreate)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath { + return s.handleAccountGet(w, r, v) // Allowed by anonymous + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath { + return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath { + return s.ensureUser(s.handleAccountPasswordChange)(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath { + return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v) + } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath { + return s.ensureUser(s.withAccountSync(s.handleAccountTokenUpdate))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountTokenPath { + return s.ensureUser(s.withAccountSync(s.handleAccountTokenDelete))(w, r, v) + } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath { + return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath { + return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionAdd))(w, r, v) + } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSubscriptionPath { + return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionChange))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountSubscriptionPath { + return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionDelete))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountReservationPath { + return s.ensureUser(s.withAccountSync(s.handleAccountReservationAdd))(w, r, v) + } else if r.Method == http.MethodDelete && apiAccountReservationSingleRegex.MatchString(r.URL.Path) { + return s.ensureUser(s.withAccountSync(s.handleAccountReservationDelete))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingSubscriptionPath { + return s.ensurePaymentsEnabled(s.ensureUser(s.handleAccountBillingSubscriptionCreate))(w, r, v) // Account sync via incoming Stripe webhook + } else if r.Method == http.MethodGet && apiAccountBillingSubscriptionCheckoutSuccessRegex.MatchString(r.URL.Path) { + return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingSubscriptionCreateSuccess))(w, r, v) // No user context! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountBillingSubscriptionPath { + return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionUpdate))(w, r, v) // Account sync via incoming Stripe webhook + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountBillingSubscriptionPath { + return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionDelete))(w, r, v) // Account sync via incoming Stripe webhook + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingPortalPath { + return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { + return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath { + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v) + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) + } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { + return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) + } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { + return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushDelete))(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { + return s.handleStats(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { + return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { + return s.handleMatrixDiscovery(w) + } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { + return s.handleMetrics(w, r, v) + } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) { + return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { - return s.handleDocs(w, r) - } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { + return s.ensureWebEnabled(s.handleDocs)(w, r, v) + } else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { return s.limitRequests(s.handleFile)(w, r, v) } else if r.Method == http.MethodOptions { - return s.handleOptions(w, r) + return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598 } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { - return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v) + return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { + return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v) + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v) + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authRead(s.handleSubscribeJSON))(w, r, v) + return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v) } else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authRead(s.handleSubscribeSSE))(w, r, v) + return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeSSE))(w, r, v) } else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authRead(s.handleSubscribeRaw))(w, r, v) + return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeRaw))(w, r, v) } else if r.Method == http.MethodGet && wsPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v) + return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { - return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v) + return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { - return s.handleTopic(w, r) + return s.ensureWebEnabled(s.handleTopic)(w, r, v) } return errHTTPNotFound } -func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { - if s.config.WebRootIsApp { - r.URL.Path = webAppIndex - } else { - r.URL.Path = webHomeIndex - } - return s.handleStatic(w, r) +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error { + r.URL.Path = webAppIndex + return s.handleStatic(w, r, v) } -func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error { +func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error { unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too! if unifiedpush { w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests _, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n") return err } r.URL.Path = webAppIndex - return s.handleStatic(w, r) + return s.handleStatic(w, r, v) } func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { @@ -328,145 +555,381 @@ func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) } func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - _, err := io.WriteString(w, `{"success":true}`+"\n") - return err + return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error { - _, err := io.WriteString(w, exampleSource) - return err -} - -func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error { - appRoot := "/" - if !s.config.WebRootIsApp { - appRoot = "/app" +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &apiHealthResponse{ + Healthy: true, } - disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"` - w.Header().Set("Content-Type", "text/javascript") - _, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration -var config = { - appRoot: "%s", - disallowedTopics: [%s] -};`, appRoot, disallowedTopicsStr)) - return err + return s.writeJSON(w, response) } -func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error { - stats, err := v.Stats() +func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &apiConfigResponse{ + BaseURL: "", // Will translate to window.location.origin + AppRoot: s.config.WebRoot, + EnableLogin: s.config.EnableLogin, + EnableSignup: s.config.EnableSignup, + EnablePayments: s.config.StripeSecretKey != "", + EnableCalls: s.config.TwilioAccount != "", + EnableEmails: s.config.SMTPSenderFrom != "", + EnableReservations: s.config.EnableReservations, + EnableWebPush: s.config.WebPushPublicKey != "", + BillingContact: s.config.BillingContact, + WebPushPublicKey: s.config.WebPushPublicKey, + DisallowedTopics: s.config.DisallowedTopics, + } + b, err := json.MarshalIndent(response, "", " ") if err != nil { return err } - w.Header().Set("Content-Type", "text/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - if err := json.NewEncoder(w).Encode(stats); err != nil { - return err + w.Header().Set("Content-Type", "text/javascript") + _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) + return err +} + +// handleWebManifest serves the web app manifest for the progressive web app (PWA) +func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &webManifestResponse{ + Name: "ntfy web", + Description: "ntfy lets you send push notifications via scripts from any computer or phone", + ShortName: "ntfy", + Scope: "/", + StartURL: s.config.WebRoot, + Display: "standalone", + BackgroundColor: "#ffffff", + ThemeColor: "#317f6f", + Icons: []*webManifestIcon{ + {SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"}, + {SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"}, + }, } + return s.writeJSONWithContentType(w, response, "application/manifest+json") +} + +// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set, +// and listen-metrics-http is not set. +func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error { + s.metricsHandler.ServeHTTP(w, r) return nil } -func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error { +// handleStatic returns all static resources (excluding the docs), including the web app +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { r.URL.Path = webSiteDir + r.URL.Path util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) return nil } -func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { +// handleDocs returns static resources related to the docs +func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error { util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r) return nil } +// handleStats returns the publicly available server stats +func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + s.mu.RLock() + messages, n, rate := s.messages, len(s.messagesHistory), float64(0) + if n > 1 { + rate = float64(s.messagesHistory[n-1]-s.messagesHistory[0]) / (float64(n-1) * s.config.ManagerInterval.Seconds()) + } + s.mu.RUnlock() + response := &apiStatsResponse{ + Messages: messages, + MessagesRate: rate, + } + return s.writeJSON(w, response) +} + +// handleFile processes the download of attachment files. The method handles GET and HEAD requests against a file. +// Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it +// can associate the download bandwidth with the uploader. func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.AttachmentCacheDir == "" { return errHTTPInternalError } matches := fileRegex.FindStringSubmatch(r.URL.Path) if len(matches) != 2 { - return errHTTPInternalErrorInvalidFilePath + return errHTTPInternalErrorInvalidPath } messageID := matches[1] file := filepath.Join(s.config.AttachmentCacheDir, messageID) stat, err := os.Stat(file) if err != nil { - return errHTTPNotFound - } - if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil { - return errHTTPTooManyRequestsAttachmentBandwidthLimit + return errHTTPNotFound.Fields(log.Context{ + "message_id": messageID, + "error_context": "filesystem", + }) } + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests + if r.Method == http.MethodHead { + return nil + } + // Find message in database, and associate bandwidth to the uploader user + // This is an easy way to + // - avoid abuse (e.g. 1 uploader, 1k downloaders) + // - and also uses the higher bandwidth limits of a paying user + m, err := s.messageCache.Message(messageID) + if err == errMessageNotFound { + if s.config.CacheBatchTimeout > 0 { + // Strange edge case: If we immediately after upload request the file (the web app does this for images), + // and messages are persisted asynchronously, retry fetching from the database + m, err = util.Retry(func() (*message, error) { + return s.messageCache.Message(messageID) + }, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond) + } + if err != nil { + return errHTTPNotFound.Fields(log.Context{ + "message_id": messageID, + "error_context": "message_cache", + }) + } + } else if err != nil { + return err + } + bandwidthVisitor := v + if s.userManager != nil && m.User != "" { + u, err := s.userManager.UserByID(m.User) + if err != nil { + return err + } + bandwidthVisitor = s.visitor(v.IP(), u) + } else if m.Sender.IsValid() { + bandwidthVisitor = s.visitor(m.Sender, nil) + } + if !bandwidthVisitor.BandwidthAllowed(stat.Size()) { + return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m) + } + // Actually send file f, err := os.Open(file) if err != nil { return err } defer f.Close() + if m.Attachment.Name != "" { + w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(m.Attachment.Name)) + } _, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f) return err } -func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := s.topicFromPath(r.URL.Path) +func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error { + if s.config.BaseURL == "" { + return errHTTPInternalErrorMissingBaseURL + } + return writeMatrixDiscoveryResponse(w) +} + +func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) { + start := time.Now() + t, err := fromContext[*topic](r, contextTopic) if err != nil { - return err + return nil, err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return nil, err } body, err := util.Peek(r.Body, s.config.MessageLimit) if err != nil { - return err + return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m) - if err != nil { - return err + cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m) + if e != nil { + return nil, e.With(t) + } + if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil { + // UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see + // Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove + // the subscription as invalid if any 400-499 code (except 429/408) is returned. + // See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46 + return nil, errHTTPInsufficientStorageUnifiedPush.With(t) + } else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() { + return nil, errHTTPTooManyRequestsLimitMessages.With(t) + } else if email != "" && !vrate.EmailAllowed() { + return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } else if call != "" { + var httpErr *errHTTP + call, httpErr = s.convertPhoneNumber(v.User(), call) + if httpErr != nil { + return nil, httpErr.With(t) + } else if !vrate.CallAllowed() { + return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } + } + if m.PollID != "" { + m = newPollRequestMessage(t.ID, m.PollID) + } + m.Sender = v.IP() + m.User = v.MaybeUserID() + if cache { + m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { - return err + return nil, err } if m.Message == "" { m.Message = emptyMessageBody } delayed := m.Time > time.Now().Unix() + ev := logvrm(v, r, m). + Tag(tagPublish). + With(t). + Fields(log.Context{ + "message_delayed": delayed, + "message_firebase": firebase, + "message_unifiedpush": unifiedpush, + "message_email": email, + "message_call": call, + }) + if ev.IsTrace() { + ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message") + } else if ev.IsDebug() { + ev.Debug("Received message") + } if !delayed { - if err := t.Publish(m); err != nil { - return err + if err := t.Publish(v, m); err != nil { + return nil, err } - } - if s.firebase != nil && firebase && !delayed { - go func() { - if err := s.firebase(m); err != nil { - log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error()) - } - }() - } - if s.mailer != nil && email != "" && !delayed { - go func() { - if err := s.mailer.Send(v.ip, email, m); err != nil { - log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error()) - } - }() + if s.firebaseClient != nil && firebase { + go s.sendToFirebase(v, m) + } + if s.smtpSender != nil && email != "" { + go s.sendEmail(v, m, email) + } + if s.config.TwilioAccount != "" && call != "" { + go s.callPhone(v, r, m, call) + } + if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream + go s.forwardPollRequest(v, m) + } + if s.config.WebPushPublicKey != "" { + go s.publishToWebPushEndpoints(v, m) + } + } else { + logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later") } if cache { + logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache") if err := s.messageCache.AddMessage(m); err != nil { - return err + return nil, err } } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - if err := json.NewEncoder(w).Encode(m); err != nil { - return err + u := v.User() + if s.userManager != nil && u != nil && u.Tier != nil { + go s.userManager.EnqueueUserStats(u.ID, v.Stats()) } s.mu.Lock() s.messages++ s.mu.Unlock() - return nil + if unifiedpush { + minc(metricUnifiedPushPublishedSuccess) + } + mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds()) + return m, nil } -func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) { +func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { + m, err := s.handlePublishInternal(r, v) + if err != nil { + minc(metricMessagesPublishedFailure) + return err + } + minc(metricMessagesPublishedSuccess) + return s.writeJSON(w, m) +} + +func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { + _, err := s.handlePublishInternal(r, v) + if err != nil { + minc(metricMessagesPublishedFailure) + minc(metricMatrixPublishedFailure) + if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode { + topic, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + pushKey, err := fromContext[string](r, contextMatrixPushKey) + if err != nil { + return err + } + if time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter { + return writeMatrixResponse(w, pushKey) + } + } + return err + } + minc(metricMessagesPublishedSuccess) + minc(metricMatrixPublishedSuccess) + return writeMatrixSuccess(w) +} + +func (s *Server) sendToFirebase(v *visitor, m *message) { + logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") + if err := s.firebaseClient.Send(v, m); err != nil { + minc(metricFirebasePublishedFailure) + if err == errFirebaseTemporarilyBanned { + logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error()) + } else { + logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error()) + } + return + } + minc(metricFirebasePublishedSuccess) +} + +func (s *Server) sendEmail(v *visitor, m *message, email string) { + logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email) + if err := s.smtpSender.Send(v, m, email); err != nil { + logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) + minc(metricEmailsPublishedFailure) + return + } + minc(metricEmailsPublishedSuccess) +} + +func (s *Server) forwardPollRequest(v *visitor, m *message) { + topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) + topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL))) + forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash) + logvm(v, m).Debug("Publishing poll request to %s", forwardURL) + req, err := http.NewRequest("POST", forwardURL, strings.NewReader("")) + if err != nil { + logvm(v, m).Err(err).Warn("Unable to publish poll request") + return + } + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("X-Poll-ID", m.ID) + if s.config.UpstreamAccessToken != "" { + req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) + } + var httpClient = &http.Client{ + Timeout: time.Second * 10, + } + response, err := httpClient.Do(req) + if err != nil { + logvm(v, m).Err(err).Warn("Unable to publish poll request") + return + } else if response.StatusCode != http.StatusOK { + if response.StatusCode == http.StatusTooManyRequests { + logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status) + } else { + logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status) + } + return + } +} + +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") + icon := readParam(r, "x-icon", "icon") filename := readParam(r, "x-filename", "filename", "file", "f") attach := readParam(r, "x-attach", "attach", "a") if attach != "" || filename != "" { @@ -476,8 +939,8 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca m.Attachment.Name = filename } if attach != "" { - if !attachURLRegex.MatchString(attach) { - return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid + if !urlRegex.MatchString(attach) { + return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -493,86 +956,111 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca m.Attachment.Name = "attachment" } } - email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") - if email != "" { - if err := v.EmailAllowed(); err != nil { - return false, false, "", false, errHTTPTooManyRequestsLimitEmails + if icon != "" { + if !urlRegex.MatchString(icon) { + return false, false, "", "", false, errHTTPBadRequestIconURLInvalid } + m.Icon = icon } - if s.mailer == nil && email != "" { - return false, false, "", false, errHTTPBadRequestEmailDisabled + email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") + if s.smtpSender == nil && email != "" { + return false, false, "", "", false, errHTTPBadRequestEmailDisabled + } + call = readParam(r, "x-call", "call") + if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { + return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled + } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { + return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { m.Message = messageStr } - m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) - if err != nil { - return false, false, "", false, errHTTPBadRequestPriorityInvalid - } - tagsStr := readParam(r, "x-tags", "tags", "tag", "ta") - if tagsStr != "" { - m.Tags = make([]string, 0) - for _, s := range util.SplitNoEmpty(tagsStr, ",") { - m.Tags = append(m.Tags, strings.TrimSpace(s)) - } + var e error + m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) + if e != nil { + return false, false, "", "", false, errHTTPBadRequestPriorityInvalid } + m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + } + if call != "" { + return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { - return false, false, "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { - return false, false, "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } actionsStr := readParam(r, "x-actions", "actions", "action") if actionsStr != "" { - m.Actions, err = parseActions(actionsStr) - if err != nil { - return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error()) + m.Actions, e = parseActions(actionsStr) + if e != nil { + return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } + contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") + if markdown || strings.ToLower(contentType) == "text/markdown" { + m.ContentType = "text/markdown" + } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! if unifiedpush { firebase = false unifiedpush = true } - return cache, firebase, email, unifiedpush, nil + m.PollID = readParam(r, "x-poll-id", "poll-id") + if m.PollID != "" { + unifiedpush = false + cache = false + email = "" + } + return cache, firebase, email, call, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. // -// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1" -// If body is binary, encode as base64, if not do not encode -// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic -// Body must be a message, because we attached an external URL -// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic -// Body must be attachment, because we passed a filename -// 4. curl -T file.txt ntfy.sh/mytopic -// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message -// 5. curl -T file.txt ntfy.sh/mytopic -// If file.txt is > message limit, treat it as an attachment +// 1. curl -X POST -H "Poll: 1234" ntfy.sh/... +// If a message is flagged as poll request, the body does not matter and is discarded +// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1" +// If body is binary, encode as base64, if not do not encode +// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic +// Body must be a message, because we attached an external URL +// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic +// Body must be attachment, because we passed a filename +// 5. curl -T file.txt ntfy.sh/mytopic +// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message +// 6. curl -T file.txt ntfy.sh/mytopic +// If file.txt is > message limit, treat it as an attachment func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error { - if unifiedpush { - return s.handleBodyAsMessageAutoDetect(m, body) // Case 1 + if m.Event == pollRequestEvent { // Case 1 + return s.handleBodyDiscard(body) + } else if unifiedpush { + return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 } else if m.Attachment != nil && m.Attachment.URL != "" { - return s.handleBodyAsTextMessage(m, body) // Case 2 + return s.handleBodyAsTextMessage(m, body) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { - return s.handleBodyAsAttachment(r, v, m, body) // Case 3 + return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { - return s.handleBodyAsTextMessage(m, body) // Case 4 + return s.handleBodyAsTextMessage(m, body) // Case 5 } - return s.handleBodyAsAttachment(r, v, m, body) // Case 5 + return s.handleBodyAsAttachment(r, v, m, body) // Case 6 +} + +func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error { + _, err := io.Copy(io.Discard, body) + _ = body.Close() + return err } func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error { @@ -587,7 +1075,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error { if !utf8.Valid(body.PeekedBytes) { - return errHTTPBadRequestMessageNotUTF8 + return errHTTPBadRequestMessageNotUTF8.With(m) } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required @@ -600,27 +1088,32 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { - return errHTTPBadRequestAttachmentsDisallowed - } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { - return errHTTPBadRequestAttachmentsExpiryBeforeDelivery + return errHTTPBadRequestAttachmentsDisallowed.With(m) } - visitorStats, err := v.Stats() + vinfo, err := v.Info() if err != nil { return err } + attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix() + if m.Time > attachmentExpiry { + return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m) + } contentLengthStr := r.Header.Get("Content-Length") if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) - if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) { - return errHTTPEntityTooLargeAttachmentTooLarge + if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) { + return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{ + "message_content_length": contentLength, + "attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining, + "attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit, + }) } } if m.Attachment == nil { m.Attachment = &attachment{} } var ext string - m.Attachment.Owner = v.ip // Important for attachment rate limiting - m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() + m.Attachment.Expires = attachmentExpiry m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name) m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) if m.Attachment.Name == "" { @@ -629,9 +1122,14 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining)) + limiters := []util.Limiter{ + v.BandwidthLimiter(), + util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit), + util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), + } + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) if err == util.ErrLimitReached { - return errHTTPEntityTooLargeAttachmentTooLarge + return errHTTPEntityTooLargeAttachment.With(m) } else if err != nil { return err } @@ -674,7 +1172,9 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v } func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error { - if err := v.SubscriptionAllowed(); err != nil { + logvr(v, r).Tag(tagSubscribe).Debug("HTTP stream connection opened") + defer logvr(v, r).Tag(tagSubscribe).Debug("HTTP stream connection closed") + if !v.SubscriptionAllowed() { return errHTTPTooManyRequestsLimitSubscriptions } defer v.RemoveSubscription() @@ -682,12 +1182,19 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * if err != nil { return err } - poll, since, scheduled, filters, err := parseSubscribeParams(r) + poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) if err != nil { return err } var wlock sync.Mutex - sub := func(msg *message) error { + defer func() { + // Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time. + // It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER + // this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the + // data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889. + wlock.TryLock() + }() + sub := func(v *visitor, msg *message) error { if !filters.Pass(msg) { return nil } @@ -705,33 +1212,52 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * } return nil } - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! - if poll { - return s.sendOldMessages(topics, since, scheduled, sub) + if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { + return err } + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests + w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! + if poll { + for _, t := range topics { + t.Keepalive() + } + return s.sendOldMessages(topics, since, scheduled, v, sub) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { topics[i].Unsubscribe(subscriberID) // Order! } }() - if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message + if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message return err } - if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil { + if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { return err } for { select { + case <-ctx.Done(): + return nil case <-r.Context().Done(): return nil case <-time.After(s.config.KeepaliveInterval): + ev := logvr(v, r).Tag(tagSubscribe) + if len(topics) == 1 { + ev.With(topics[0]).Trace("Sending keepalive message to %s", topics[0].ID) + } else { + ev.Trace("Sending keepalive message to %d topics", len(topics)) + } v.Keepalive() - if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message + for _, t := range topics { + t.Keepalive() + } + if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message return err } } @@ -742,15 +1268,17 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi if strings.ToLower(r.Header.Get("Upgrade")) != "websocket" { return errHTTPBadRequestWebSocketsUpgradeHeaderMissing } - if err := v.SubscriptionAllowed(); err != nil { + if !v.SubscriptionAllowed() { return errHTTPTooManyRequestsLimitSubscriptions } defer v.RemoveSubscription() + logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection opened") + defer logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection closed") topics, topicsStr, err := s.topicsFromPath(r.URL.Path) if err != nil { return err } - poll, since, scheduled, filters, err := parseSubscribeParams(r) + poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) if err != nil { return err } @@ -766,8 +1294,14 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi return err } defer conn.Close() + + // Subscription connections can be canceled externally, see topic.CancelSubscribersExceptUser + cancelCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Use errgroup to run WebSocket reader and writer in Go routines var wlock sync.Mutex - g, ctx := errgroup.WithContext(context.Background()) + g, gctx := errgroup.WithContext(cancelCtx) g.Go(func() error { pongWait := s.config.KeepaliveInterval + wsPongWait conn.SetReadLimit(wsReadLimit) @@ -775,6 +1309,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi return err } conn.SetPongHandler(func(appData string) error { + logvr(v, r).Tag(tagWebsocket).Trace("Received WebSocket pong") return conn.SetReadDeadline(time.Now().Add(pongWait)) }) for { @@ -782,6 +1317,11 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi if err != nil { return err } + select { + case <-gctx.Done(): + return nil + default: + } } }) g.Go(func() error { @@ -791,21 +1331,29 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil { return err } + logvr(v, r).Tag(tagWebsocket).Trace("Sending WebSocket ping") return conn.WriteMessage(websocket.PingMessage, nil) } for { select { - case <-ctx.Done(): + case <-gctx.Done(): return nil + case <-cancelCtx.Done(): + logvr(v, r).Tag(tagWebsocket).Trace("Cancel received, closing subscriber connection") + conn.Close() + return &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "subscription was canceled"} case <-time.After(s.config.KeepaliveInterval): v.Keepalive() + for _, t := range topics { + t.Keepalive() + } if err := ping(); err != nil { return err } } } }) - sub := func(msg *message) error { + sub := func(v *visitor, msg *message) error { if !filters.Pass(msg) { return nil } @@ -816,33 +1364,40 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } return conn.WriteJSON(msg) } - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests + if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { + return err + } + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests if poll { - return s.sendOldMessages(topics, since, scheduled, sub) + for _, t := range topics { + t.Keepalive() + } + return s.sendOldMessages(topics, since, scheduled, v, sub) } subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { topics[i].Unsubscribe(subscriberID) // Order! } }() - if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message + if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message return err } - if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil { + if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { return err } err = g.Wait() - if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - return nil // Normal closures are not errors + if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) { + logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed") + return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot } return err } -func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) { +func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) { poll = readBoolParam(r, false, "x-poll", "poll", "po") scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched") since, err = parseSince(r, poll) @@ -853,22 +1408,93 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu if err != nil { return } + rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics") return } -func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error { - if since.IsNone() { +// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published +// to that topic will be rate limited against the rate visitor instead of the publishing visitor. +// +// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND +// - auth-file is not set (everything is open by default) +// - or the topic is reserved, and v.user is the owner +// - or the topic is not reserved, and v.user has write access +// +// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition +// until the Android app will send the "Rate-Topics" header. +func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error { + // Bail out if not enabled + if !s.config.VisitorSubscriberRateLimiting { return nil } + + // Make a list of topics that we'll actually set the RateVisitor on + eligibleRateTopics := make([]*topic, 0) for _, t := range topics { - messages, err := s.messageCache.Messages(t.ID, since, scheduled) + if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) { + eligibleRateTopics = append(eligibleRateTopics, t) + } + } + if len(eligibleRateTopics) == 0 { + return nil + } + + // If access controls are turned off, v has access to everything, and we can set the rate visitor + if s.userManager == nil { + return s.setRateVisitors(r, v, eligibleRateTopics) + } + + // If access controls are enabled, only set rate visitor if + // - topic is reserved, and v.user is the owner + // - topic is not reserved, and v.user has write access + writableRateTopics := make([]*topic, 0) + for _, t := range topics { + ownerUserID, err := s.userManager.ReservationOwner(t.ID) if err != nil { return err } - for _, m := range messages { - if err := sub(m); err != nil { - return err + if ownerUserID == "" { + if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil { + writableRateTopics = append(writableRateTopics, t) } + } else if ownerUserID == v.MaybeUserID() { + writableRateTopics = append(writableRateTopics, t) + } + } + return s.setRateVisitors(r, v, writableRateTopics) +} + +func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topic) error { + for _, t := range rateTopics { + logvr(v, r). + Tag(tagSubscribe). + With(t). + Debug("Setting visitor as rate visitor for topic %s", t.ID) + t.SetRateVisitor(v) + } + return nil +} + +// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the +// marker, returning only messages that are newer than the marker. +func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error { + if since.IsNone() { + return nil + } + messages := make([]*message, 0) + for _, t := range topics { + topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled) + if err != nil { + return err + } + messages = append(messages, topicMessages...) + } + sort.Slice(messages, func(i, j int) bool { + return messages[i].Time < messages[j].Time + }) + for _, m := range messages { + if err := sub(v, m); err != nil { + return err } } return nil @@ -904,25 +1530,23 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) { return sinceNoMessages, errHTTPBadRequestSinceInvalid } -func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error { - w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? +func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE") + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests + w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? return nil } +// topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist. func (s *Server) topicFromPath(path string) (*topic, error) { parts := strings.Split(path, "/") if len(parts) < 2 { return nil, errHTTPBadRequestTopicInvalid } - topics, err := s.topicsFromIDs(parts[1]) - if err != nil { - return nil, err - } - return topics[0], nil + return s.topicFromID(parts[1]) } +// topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist. func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { parts := strings.Split(path, "/") if len(parts) < 2 { @@ -936,12 +1560,13 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } +// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() defer s.mu.Unlock() topics := make([]*topic, 0) for _, id := range ids { - if util.InStringList(disallowedTopics, id) { + if util.Contains(s.config.DisallowedTopics, id) { return nil, errHTTPBadRequestTopicDisallowed } if _, ok := s.topics[id]; !ok { @@ -955,82 +1580,35 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { return topics, nil } -func (s *Server) updateStatsAndPrune() { - s.mu.Lock() - defer s.mu.Unlock() - - // Expire visitors from rate visitors map - for ip, v := range s.visitors { - if v.Stale() { - delete(s.visitors, ip) - } +// topicFromID returns the topic with the given ID, creating it if it doesn't exist. +func (s *Server) topicFromID(id string) (*topic, error) { + topics, err := s.topicsFromIDs(id) + if err != nil { + return nil, err } + return topics[0], nil +} - // Delete expired attachments - if s.fileCache != nil { - ids, err := s.messageCache.AttachmentsExpired() - if err == nil { - if err := s.fileCache.Remove(ids...); err != nil { - log.Printf("error while deleting attachments: %s", err.Error()) - } - } else { - log.Printf("error retrieving expired attachments: %s", err.Error()) - } +// topicsFromPattern returns a list of topics matching the given pattern, but it does not create them. +func (s *Server) topicsFromPattern(pattern string) ([]*topic, error) { + s.mu.RLock() + defer s.mu.RUnlock() + patternRegexp, err := regexp.Compile("^" + strings.ReplaceAll(pattern, "*", ".*") + "$") + if err != nil { + return nil, err } - - // Prune message cache - olderThan := time.Now().Add(-1 * s.config.CacheDuration) - if err := s.messageCache.Prune(olderThan); err != nil { - log.Printf("error pruning cache: %s", err.Error()) - } - - // Prune old topics, remove subscriptions without subscribers - var subscribers, messages int + topics := make([]*topic, 0) for _, t := range s.topics { - subs := t.Subscribers() - msgs, err := s.messageCache.MessageCount(t.ID) - if err != nil { - log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error()) - continue + if patternRegexp.MatchString(t.ID) { + topics = append(topics, t) } - if msgs == 0 && subs == 0 { - delete(s.topics, t.ID) - continue - } - subscribers += subs - messages += msgs } - - // Mail stats - var mailSuccess, mailFailure int64 - if s.smtpBackend != nil { - mailSuccess, mailFailure = s.smtpBackend.Counts() - } - - // Print stats - log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)", - s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors)) + return topics, nil } func (s *Server) runSMTPServer() error { - sub := func(m *message) error { - url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) - req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message)) - if err != nil { - return err - } - if m.Title != "" { - req.Header.Set("Title", m.Title) - } - rr := httptest.NewRecorder() - s.handle(rr, req) - if rr.Code != http.StatusOK { - return errors.New("error: " + rr.Body.String()) - } - return nil - } - s.smtpBackend = newMailBackend(s.config, sub) - s.smtpServer = smtp.NewServer(s.smtpBackend) + s.smtpServerBackend = newMailBackend(s.config, s.handle) + s.smtpServer = smtp.NewServer(s.smtpServerBackend) s.smtpServer.Addr = s.config.SMTPServerListen s.smtpServer.Domain = s.config.SMTPServerDomain s.smtpServer.ReadTimeout = 10 * time.Second @@ -1045,35 +1623,78 @@ func (s *Server) runManager() { for { select { case <-time.After(s.config.ManagerInterval): - s.updateStatsAndPrune() + log. + Tag(tagManager). + Timing(s.execManager). + Debug("Manager finished") case <-s.closeChan: return } } } -func (s *Server) runAtSender() { +// runStatsResetter runs once a day (usually midnight UTC) to reset all the visitor's message and +// email counters. The stats are used to display the counters in the web app, as well as for rate limiting. +func (s *Server) runStatsResetter() { for { + runAt := util.NextOccurrenceUTC(s.config.VisitorStatsResetTime, time.Now()) + timer := time.NewTimer(time.Until(runAt)) + log.Tag(tagResetter).Debug("Waiting until %v to reset visitor stats", runAt) select { - case <-time.After(s.config.AtSenderInterval): - if err := s.sendDelayedMessages(); err != nil { - log.Printf("error sending scheduled messages: %s", err.Error()) - } + case <-timer.C: + log.Tag(tagResetter).Debug("Running stats resetter") + s.resetStats() case <-s.closeChan: + log.Tag(tagResetter).Debug("Stopping stats resetter") + timer.Stop() return } } } +func (s *Server) resetStats() { + log.Info("Resetting all visitor stats (daily task)") + s.mu.Lock() + defer s.mu.Unlock() // Includes the database query to avoid races with other processes + for _, v := range s.visitors { + v.ResetStats() + } + if s.userManager != nil { + if err := s.userManager.ResetStats(); err != nil { + log.Tag(tagResetter).Warn("Failed to write to database: %s", err.Error()) + } + } +} + func (s *Server) runFirebaseKeepaliver() { - if s.firebase == nil { + if s.firebaseClient == nil { return } + v := newVisitor(s.config, s.messageCache, s.userManager, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0 for { select { case <-time.After(s.config.FirebaseKeepaliveInterval): - if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil { - log.Printf("error sending Firebase keepalive message: %s", err.Error()) + s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic)) + /* + FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677) + To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly. + Given that it's not really necessary to poll, turning it off for now should not have any impact. + + case <-time.After(s.config.FirebasePollInterval): + s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) + */ + case <-s.closeChan: + return + } + } +} + +func (s *Server) runDelayedSender() { + for { + select { + case <-time.After(s.config.DelayedSenderInterval): + if err := s.sendDelayedMessages(); err != nil { + log.Tag(tagPublish).Err(err).Warn("Error sending delayed messages") } case <-s.closeChan: return @@ -1082,55 +1703,63 @@ func (s *Server) runFirebaseKeepaliver() { } func (s *Server) sendDelayedMessages() error { - s.mu.Lock() - defer s.mu.Unlock() messages, err := s.messageCache.MessagesDue() if err != nil { return err } for _, m := range messages { - t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published - if ok { - if err := t.Publish(m); err != nil { - log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error()) + var u *user.User + if s.userManager != nil && m.User != "" { + u, err = s.userManager.UserByID(m.User) + if err != nil { + log.With(m).Err(err).Warn("Error sending delayed message") + continue } } - if s.firebase != nil { // Firebase subscribers may not show up in topics map - if err := s.firebase(m); err != nil { - log.Printf("unable to publish to Firebase: %v", err.Error()) - } - } - if err := s.messageCache.MarkPublished(m); err != nil { - return err + v := s.visitor(m.Sender, u) + if err := s.sendDelayedMessage(v, m); err != nil { + logvm(v, m).Err(err).Warn("Error sending delayed message") } } return nil } -func (s *Server) limitRequests(next handleFunc) handleFunc { - return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if util.InStringList(s.config.VisitorRequestExemptIPAddrs, v.ip) { - return next(w, r, v) - } else if err := v.RequestAllowed(); err != nil { - return errHTTPTooManyRequestsLimitRequests - } - return next(w, r, v) +func (s *Server) sendDelayedMessage(v *visitor, m *message) error { + logvm(v, m).Debug("Sending delayed message") + s.mu.RLock() + t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published + s.mu.RUnlock() + if ok { + go func() { + // We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler + if err := t.Publish(v, m); err != nil { + logvm(v, m).Err(err).Warn("Unable to publish message") + } + }() } + if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map + go s.sendToFirebase(v, m) + } + if s.config.UpstreamBaseURL != "" { + go s.forwardPollRequest(v, m) + } + if s.config.WebPushPublicKey != "" { + go s.publishToWebPushEndpoints(v, m) + } + if err := s.messageCache.MarkPublished(m); err != nil { + return err + } + return nil } // transformBodyJSON peeks the request body, reads the JSON, and converts it to headers // before passing it on to the next handler. This is meant to be used in combination with handlePublish. func (s *Server) transformBodyJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - body, err := util.Peek(r.Body, s.config.MessageLimit) + m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead if err != nil { return err } - defer r.Body.Close() - var m publishMessage - if err := json.NewDecoder(body).Decode(&m); err != nil { - return errHTTPBadRequestJSONInvalid - } if !topicRegex.MatchString(m.Topic) { return errHTTPBadRequestTopicInvalid } @@ -1157,10 +1786,16 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Click != "" { r.Header.Set("X-Click", m.Click) } + if m.Icon != "" { + r.Header.Set("X-Icon", m.Icon) + } + if m.Markdown { + r.Header.Set("X-Markdown", "yes") + } if len(m.Actions) > 0 { actionsStr, err := json.Marshal(m.Actions) if err != nil { - return errHTTPBadRequestJSONInvalid + return errHTTPBadRequestMessageJSONInvalid } r.Header.Set("X-Actions", string(actionsStr)) } @@ -1170,84 +1805,192 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Delay != "" { r.Header.Set("X-Delay", m.Delay) } + if m.Call != "" { + r.Header.Set("X-Call", m.Call) + } return next(w, r, v) } } -func (s *Server) authWrite(next handleFunc) handleFunc { - return s.withAuth(next, auth.PermissionWrite) -} - -func (s *Server) authRead(next handleFunc) handleFunc { - return s.withAuth(next, auth.PermissionRead) -} - -func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc { +func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.auth == nil { + newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit) + if err != nil { + logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request") + if e, ok := err.(*errMatrixPushkeyRejected); ok { + return writeMatrixResponse(w, e.rejectedPushKey) + } + return err + } + if err := next(w, newRequest, v); err != nil { + logvr(v, r).Tag(tagMatrix).Err(err).Debug("Error handling Matrix request") + return err + } + return nil + } +} + +func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc { + return s.autorizeTopic(next, user.PermissionWrite) +} + +func (s *Server) authorizeTopicRead(next handleFunc) handleFunc { + return s.autorizeTopic(next, user.PermissionRead) +} + +func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.userManager == nil { return next(w, r, v) } topics, _, err := s.topicsFromPath(r.URL.Path) if err != nil { return err } - var user *auth.User // may stay nil if no auth header! - username, password, ok := extractUserPass(r) - if ok { - if user, err = s.auth.Authenticate(username, password); err != nil { - log.Printf("authentication failed: %s", err.Error()) - return errHTTPUnauthorized - } - } + u := v.User() for _, t := range topics { - if err := s.auth.Authorize(user, t.ID, perm); err != nil { - log.Printf("unauthorized: %s", err.Error()) - return errHTTPForbidden + if err := s.userManager.Authorize(u, t.ID, perm); err != nil { + logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) + return errHTTPForbidden.With(t) } } return next(w, r, v) } } -// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...), -// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript -// class, which does not support passing headers during the initial request. The auth query param -// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)). -func extractUserPass(r *http.Request) (username string, password string, ok bool) { - username, password, ok = r.BasicAuth() - if ok { - return +// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user +// if it is set. +// +// - If auth-file is not configured, immediately return an IP-based visitor +// - If the header is not set or not supported (anything non-Basic and non-Bearer), +// an IP-based visitor is returned +// - If the header is set, authenticate will be called to check the username/password (Basic auth), +// or the token (Bearer auth), and read the user from the database +// +// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so +// that subsequent logging calls still have a visitor context. +func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { + // Read "Authorization" header value, and exit out early if it's not set + ip := extractIPAddress(r, s.config.BehindProxy) + vip := s.visitor(ip, nil) + if s.userManager == nil { + return vip, nil } - authParam := readQueryParam(r, "authorization", "auth") - if authParam != "" { - a, err := base64.RawURLEncoding.DecodeString(authParam) - if err != nil { - return - } - r.Header.Set("Authorization", string(a)) - return r.BasicAuth() + header, err := readAuthHeader(r) + if err != nil { + return vip, err + } else if !supportedAuthHeader(header) { + return vip, nil } - return + // If we're trying to auth, check the rate limiter first + if !vip.AuthAllowed() { + return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! + } + u, err := s.authenticate(r, header) + if err != nil { + vip.AuthFailed() + logr(r).Err(err).Debug("Authentication failed") + return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! + } + // Authentication with user was successful + return s.visitor(ip, u), nil } -// visitor creates or retrieves a rate.Limiter for the given visitor. -// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). -func (s *Server) visitor(r *http.Request) *visitor { +// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). +// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to +// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth +// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)). +func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) { + if strings.HasPrefix(header, "Bearer") { + return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer"))) + } + return s.authenticateBasicAuth(r, header) +} + +// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header, +// or from the ?auth... query parameter +func readAuthHeader(r *http.Request) (string, error) { + value := strings.TrimSpace(r.Header.Get("Authorization")) + queryParam := readQueryParam(r, "authorization", "auth") + if queryParam != "" { + a, err := base64.RawURLEncoding.DecodeString(queryParam) + if err != nil { + return "", err + } + value = strings.TrimSpace(string(a)) + } + return value, nil +} + +// supportedAuthHeader returns true only if the Authorization header value starts +// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither +// are things like "WebPush", or "vapid" (see #629). +func supportedAuthHeader(value string) bool { + value = strings.ToLower(value) + return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ") +} + +func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) { + r.Header.Set("Authorization", value) + username, password, ok := r.BasicAuth() + if !ok { + return nil, errors.New("invalid basic auth") + } else if username == "" { + return s.authenticateBearerAuth(r, password) // Treat password as token + } + return s.userManager.Authenticate(username, password) +} + +func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) { + u, err := s.userManager.AuthenticateToken(token) + if err != nil { + return nil, err + } + ip := extractIPAddress(r, s.config.BehindProxy) + go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{ + LastAccess: time.Now(), + LastOrigin: ip, + }) + return u, nil +} + +func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor { s.mu.Lock() defer s.mu.Unlock() - remoteAddr := r.RemoteAddr - ip, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - ip = remoteAddr // This should not happen in real life; only in tests. - } - if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" { - ip = r.Header.Get("X-Forwarded-For") - } - v, exists := s.visitors[ip] + id := visitorID(ip, user) + v, exists := s.visitors[id] if !exists { - s.visitors[ip] = newVisitor(s.config, s.messageCache, ip) - return s.visitors[ip] + s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user) + return s.visitors[id] } v.Keepalive() + v.SetUser(user) // Always update with the latest user, may be nil! return v } + +func (s *Server) writeJSON(w http.ResponseWriter, v any) error { + return s.writeJSONWithContentType(w, v, "application/json") +} + +func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests + if err := json.NewEncoder(w).Encode(v); err != nil { + return err + } + return nil +} + +func (s *Server) updateAndWriteStats(messagesCount int64) { + s.mu.Lock() + s.messagesHistory = append(s.messagesHistory, messagesCount) + if len(s.messagesHistory) > messagesHistoryMax { + s.messagesHistory = s.messagesHistory[1:] + } + s.mu.Unlock() + go func() { + if err := s.messageCache.UpdateStats(messagesCount); err != nil { + log.Tag(tagManager).Err(err).Warn("Cannot write messages stats") + } + }() +} diff --git a/server/server.yml b/server/server.yml index 3265c751..b044a914 100644 --- a/server/server.yml +++ b/server/server.yml @@ -1,7 +1,15 @@ # ntfy server config file +# +# Please refer to the documentation at https://ntfy.sh/docs/config/ for details. +# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec. # Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com) -# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only). +# +# This setting is required for any of the following features: +# - attachments (to return a download URL) +# - e-mail sending (for the topic URL in the email footer) +# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic) +# - Matrix Push Gateway (to validate that the pushkey is correct) # # base-url: @@ -18,6 +26,7 @@ # This can be useful to avoid port issues on local systems, and to simplify permissions. # # listen-unix: +# listen-unix-mode: # Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set. # @@ -29,14 +38,28 @@ # # firebase-key-file: -# If set, messages are cached in a local SQLite database instead of only in-memory. This -# allows for service restarts without losing messages in support of the since= parameter. +# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory. +# This allows for service restarts without losing messages in support of the since= parameter. # # The "cache-duration" parameter defines the duration for which messages will be buffered # before they are deleted. This is required to support the "since=..." and "poll=1" parameter. # To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0. # The cache file is created automatically, provided that the correct permissions are set. # +# The "cache-startup-queries" parameter allows you to run commands when the database is initialized, +# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)). +# Example: +# cache-startup-queries: | +# pragma journal_mode = WAL; +# pragma synchronous = normal; +# pragma temp_store = memory; +# pragma busy_timeout = 15000; +# vacuum; +# +# The "cache-batch-size" and "cache-batch-timeout" parameter allow enabling async batch writing +# of messages. If set, messages will be queued and written to the database in batches of the given +# size, or after the given timeout. This is only required for high volume servers. +# # Debian/RPM package users: # Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package # creates this folder for you. @@ -47,6 +70,9 @@ # # cache-file: # cache-duration: "12h" +# cache-startup-queries: +# cache-batch-size: 0 +# cache-batch-timeout: "0ms" # If set, access to the ntfy server and API can be controlled on a granular level using # the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs. @@ -54,6 +80,8 @@ # - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist # - auth-default-access defines the default/fallback access if no access control entry is found; it can be # set to "read-write" (default), "read-only", "write-only" or "deny-all". +# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable +# WAL mode. This is similar to cache-startup-queries. See above for details. # # Debian/RPM package users: # Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package @@ -65,6 +93,7 @@ # # auth-file: # auth-default-access: "read-write" +# auth-startup-queries: # If set, the X-Forwarded-For header is used to determine the visitor IP address # instead of the remote address of the connection. @@ -88,18 +117,19 @@ # attachment-expiry-duration: "3h" # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, -# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only -# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings -# below (visitor-email-limit-burst & visitor-email-limit-burst). +# messages will additionally be sent out as e-mail using an external SMTP server. +# +# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported. +# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst). # # - smtp-sender-addr is the hostname:port of the SMTP server -# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user # - smtp-sender-from is the e-mail address of the sender +# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth) # # smtp-sender-addr: +# smtp-sender-from: # smtp-sender-user: # smtp-sender-pass: -# smtp-sender-from: # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # emails to a topic e-mail address to publish messages to a topic. @@ -114,6 +144,39 @@ # smtp-server-domain: # smtp-server-addr-prefix: +# Web Push support (background notifications for browsers) +# +# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users +# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push +# endpoint, which will then forward it to the browser. +# +# You must configure web-push-public/private key, web-push-file, and web-push-email-address below to enable Web Push. +# Run "ntfy webpush keys" to generate the keys. +# +# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 +# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 +# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` +# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com` +# - web-push-startup-queries is an optional list of queries to run on startup` +# +# web-push-public-key: +# web-push-private-key: +# web-push-file: +# web-push-email-address: +# web-push-startup-queries: + +# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. +# +# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 +# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 +# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 +# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +# +# twilio-account: +# twilio-auth-token: +# twilio-phone-number: +# twilio-verify-service: + # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. # @@ -126,10 +189,52 @@ # # manager-interval: "1m" -# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the -# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home". +# Defines topic names that are not allowed, because they are otherwise used. There are a few default topics +# that cannot be used (e.g. app, account, settings, ...). To extend the default list, define them here. # -# web-root: app +# Example: +# disallowed-topics: +# - about +# - pricing +# - contact +# +# disallowed-topics: + +# Defines the root path of the web app, or disables the web app entirely. +# +# Can be any simple path, e.g. "/", "/app", or "/ntfy". For backwards-compatibility reasons, +# the values "app" (maps to "/"), "home" (maps to "/app"), or "disable" (maps to "") to disable +# the web app entirely. +# +# web-root: / + +# Various feature flags used to control the web app, and API access, mainly around user and +# account management. +# +# - enable-signup allows users to sign up via the web app, or API +# - enable-login allows users to log in via the web app, or API +# - enable-reservations allows users to reserve topics (if their tier allows it) +# +# enable-signup: false +# enable-login: false +# enable-reservations: false + +# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh"). +# +# iOS users: +# If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this: +# upstream-base-url: "https://ntfy.sh" +# +# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing +# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents. +# This is to prevent the upstream server and Firebase/APNS from being able to read the message. +# +# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh". +# - upstream-access-token is the token used to authenticate with the upstream server. This is only required +# if you exceed the upstream rate limits, or the uptream server requires authentication. +# +# upstream-base-url: +# upstream-access-token: # Rate limiting: Total number of topics before the server rejects new topics. # @@ -142,13 +247,20 @@ # Rate limiting: Allowed GET/PUT/POST requests per second, per visitor: # - visitor-request-limit-burst is the initial bucket of requests each visitor has # - visitor-request-limit-replenish is the rate at which the bucket is refilled -# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames and IPs to be -# exempt from request rate limiting; hostnames are resolved at the time the server is started +# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames, IPs or CIDRs to be +# exempt from request rate limiting. Hostnames are resolved at the time the server is started. +# Example: "1.2.3.4,ntfy.example.com,8.7.6.0/24" # # visitor-request-limit-burst: 60 # visitor-request-limit-replenish: "5s" # visitor-request-limit-exempt-hosts: "" +# Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset +# every day at midnight UTC. If the limit is not set (or set to zero), the request +# limit (see above) governs the upper limit. +# +# visitor-message-daily-limit: 0 + # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled @@ -162,3 +274,90 @@ # # visitor-attachment-total-size-limit: "100M" # visitor-attachment-daily-bandwidth-limit: "500M" + +# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush) +# +# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed +# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume +# publishers (e.g. Matrix/Mastodon servers) are allowed to send. +# +# Once enabled, a client may send a "Rate-Topics: ,,..." header when subscribing to topics via +# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits +# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic. +# +# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if +# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit". +# +# visitor-subscriber-rate-limiting: false + +# Payments integration via Stripe +# +# - stripe-secret-key is the key used for the Stripe API communication. Setting this values +# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys. +# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe. +# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks. +# - billing-contact is an email address or website displayed in the "Upgrade tier" dialog to let people reach +# out with billing questions. If unset, nothing will be displayed. +# +# stripe-secret-key: +# stripe-webhook-key: +# billing-contact: + +# Metrics +# +# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port. +# Metrics may be considered sensitive information, so before you enable them, be sure you know what you are +# doing, and/or secure access to the endpoint in your reverse proxy. +# +# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) +# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly +# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" +# +# enable-metrics: false +# metrics-listen-http: + +# Profiling +# +# ntfy can expose Go's net/http/pprof endpoints to support profiling of the ntfy server. If enabled, ntfy will listen +# on a dedicated listen IP/port, which can be accessed via the web browser on http://:/debug/pprof/. +# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details. +# +# profile-listen-http: + +# Logging options +# +# By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format. +# ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular +# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded +# by calling "kill -HUP $pid" or "systemctl reload ntfy". +# +# - log-format defines the output format, can be "text" (default) or "json" +# - log-file is a filename to write logs to. If this is not set, ntfy logs to stderr. +# - log-level defines the default log level, can be one of "trace", "debug", "info" (default), "warn" or "error". +# Be aware that "debug" (and particularly "trace") can be VERY CHATTY. Only turn them on briefly for debugging purposes. +# - log-level-overrides lets you override the log level if certain fields match. This is incredibly powerful +# for debugging certain parts of the system (e.g. only the account management, or only a certain visitor). +# This is an array of strings in the format: +# - "field=value -> level" to match a value exactly, e.g. "tag=manager -> trace" +# - "field -> level" to match any value, e.g. "time_taken_ms -> debug" +# Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging. +# +# Check your permissions: +# If you are running ntfy with systemd, make sure this log file is owned by the +# ntfy user and group by running: chown ntfy.ntfy . +# +# Example (good for production): +# log-level: info +# log-format: json +# log-file: /var/log/ntfy.log +# +# Example level overrides (for debugging, only use temporarily): +# log-level-overrides: +# - "tag=manager -> trace" +# - "visitor_ip=1.2.3.4 -> debug" +# - "time_taken_ms -> debug" +# +# log-level: info +# log-level-overrides: +# log-format: text +# log-file: diff --git a/server/server_account.go b/server/server_account.go new file mode 100644 index 00000000..32b6153f --- /dev/null +++ b/server/server_account.go @@ -0,0 +1,628 @@ +package server + +import ( + "encoding/json" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "net/http" + "net/netip" + "strings" + "time" +) + +const ( + syncTopicAccountSyncEvent = "sync" + tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much +) + +func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + if !u.IsAdmin() { // u may be nil, but that's fine + if !s.config.EnableSignup { + return errHTTPBadRequestSignupNotEnabled + } else if u != nil { + return errHTTPUnauthorized // Cannot create account from user context + } + if !v.AccountCreationAllowed() { + return errHTTPTooManyRequestsLimitAccountCreation + } + } + newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil { + return errHTTPConflictUserExists + } + logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username) + if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil { + return err + } + v.AccountCreated() + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error { + info, err := v.Info() + if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats") + limits, stats := info.Limits, info.Stats + response := &apiAccountResponse{ + Limits: &apiAccountLimits{ + Basis: string(limits.Basis), + Messages: limits.MessageLimit, + MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), + Emails: limits.EmailLimit, + Calls: limits.CallLimit, + Reservations: limits.ReservationsLimit, + AttachmentTotalSize: limits.AttachmentTotalSizeLimit, + AttachmentFileSize: limits.AttachmentFileSizeLimit, + AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()), + AttachmentBandwidth: limits.AttachmentBandwidthLimit, + }, + Stats: &apiAccountStats{ + Messages: stats.Messages, + MessagesRemaining: stats.MessagesRemaining, + Emails: stats.Emails, + EmailsRemaining: stats.EmailsRemaining, + Calls: stats.Calls, + CallsRemaining: stats.CallsRemaining, + Reservations: stats.Reservations, + ReservationsRemaining: stats.ReservationsRemaining, + AttachmentTotalSize: stats.AttachmentTotalSize, + AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, + }, + } + u := v.User() + if u != nil { + response.Username = u.Name + response.Role = string(u.Role) + response.SyncTopic = u.SyncTopic + if u.Prefs != nil { + if u.Prefs.Language != nil { + response.Language = *u.Prefs.Language + } + if u.Prefs.Notification != nil { + response.Notification = u.Prefs.Notification + } + if u.Prefs.Subscriptions != nil { + response.Subscriptions = u.Prefs.Subscriptions + } + } + if u.Tier != nil { + response.Tier = &apiAccountTier{ + Code: u.Tier.Code, + Name: u.Tier.Name, + } + } + if u.Billing.StripeCustomerID != "" { + response.Billing = &apiAccountBilling{ + Customer: true, + Subscription: u.Billing.StripeSubscriptionID != "", + Status: string(u.Billing.StripeSubscriptionStatus), + Interval: string(u.Billing.StripeSubscriptionInterval), + PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(), + CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), + } + } + if s.config.EnableReservations { + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.Topic, + Everyone: r.Everyone.String(), + }) + } + } + } + tokens, err := s.userManager.Tokens(u.ID) + if err != nil { + return err + } + if len(tokens) > 0 { + response.Tokens = make([]*apiAccountTokenResponse, 0) + for _, t := range tokens { + var lastOrigin string + if t.LastOrigin != netip.IPv4Unspecified() { + lastOrigin = t.LastOrigin.String() + } + response.Tokens = append(response.Tokens, &apiAccountTokenResponse{ + Token: t.Value, + Label: t.Label, + LastAccess: t.LastAccess.Unix(), + LastOrigin: lastOrigin, + Expires: t.Expires.Unix(), + }) + } + } + if s.config.TwilioAccount != "" { + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + if len(phoneNumbers) > 0 { + response.PhoneNumbers = phoneNumbers + } + } + } else { + response.Username = user.Everyone + response.Role = string(user.RoleAnonymous) + } + return s.writeJSON(w, response) +} + +func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if req.Password == "" { + return errHTTPBadRequest + } + u := v.User() + if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { + return errHTTPBadRequestIncorrectPasswordConfirmation + } + if s.webPush != nil && u.ID != "" { + if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil { + logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name) + } + } + if u.Billing.StripeSubscriptionID != "" { + logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name) + if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil { + return err + } + } + if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, 0); err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Info("Marking user %s as deleted", u.Name) + if err := s.userManager.MarkUserRemoved(u); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if req.Password == "" || req.NewPassword == "" { + return errHTTPBadRequest + } + u := v.User() + if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { + return errHTTPBadRequestIncorrectPasswordConfirmation + } + logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name) + if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + if err != nil { + return err + } + var label string + if req.Label != nil { + label = *req.Label + } + expires := time.Now().Add(tokenExpiryDuration) + if req.Expires != nil { + expires = time.Unix(*req.Expires, 0) + } + u := v.User() + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "token_label": label, + "token_expires": expires, + }). + Debug("Creating token for user %s", u.Name) + token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP()) + if err != nil { + return err + } + response := &apiAccountTokenResponse{ + Token: token.Value, + Label: token.Label, + LastAccess: token.LastAccess.Unix(), + LastOrigin: token.LastOrigin.String(), + Expires: token.Expires.Unix(), + } + return s.writeJSON(w, response) +} + +func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + if err != nil { + return err + } else if req.Token == "" { + req.Token = u.Token + if req.Token == "" { + return errHTTPBadRequestNoTokenProvided + } + } + var expires *time.Time + if req.Expires != nil { + expires = util.Time(time.Unix(*req.Expires, 0)) + } else if req.Label == nil { + expires = util.Time(time.Now().Add(tokenExpiryDuration)) // If label/expires not set, extend token by 72 hours + } + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "token_label": req.Label, + "token_expires": expires, + }). + Debug("Updating token for user %s as deleted", u.Name) + token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires) + if err != nil { + return err + } + response := &apiAccountTokenResponse{ + Token: token.Value, + Label: token.Label, + LastAccess: token.LastAccess.Unix(), + LastOrigin: token.LastOrigin.String(), + Expires: token.Expires.Unix(), + } + return s.writeJSON(w, response) +} + +func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path + if token == "" { + token = u.Token + if token == "" { + return errHTTPBadRequestNoTokenProvided + } + } + if err := s.userManager.RemoveToken(u.ID, token); err != nil { + return err + } + logvr(v, r). + Tag(tagAccount). + Field("token", token). + Debug("Deleted token for user %s", u.Name) + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error { + newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + u := v.User() + if u.Prefs == nil { + u.Prefs = &user.Prefs{} + } + prefs := u.Prefs + if newPrefs.Language != nil { + prefs.Language = newPrefs.Language + } + if newPrefs.Notification != nil { + if prefs.Notification == nil { + prefs.Notification = &user.NotificationPrefs{} + } + if newPrefs.Notification.DeleteAfter != nil { + prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter + } + if newPrefs.Notification.Sound != nil { + prefs.Notification.Sound = newPrefs.Notification.Sound + } + if newPrefs.Notification.MinPriority != nil { + prefs.Notification.MinPriority = newPrefs.Notification.MinPriority + } + } + logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name) + if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + u := v.User() + prefs := u.Prefs + if prefs == nil { + prefs = &user.Prefs{} + } + for _, subscription := range prefs.Subscriptions { + if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { + return errHTTPConflictSubscriptionExists + } + } + prefs.Subscriptions = append(prefs.Subscriptions, newSubscription) + logvr(v, r).Tag(tagAccount).With(newSubscription).Debug("Adding subscription for user %s", u.Name) + if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil { + return err + } + return s.writeJSON(w, newSubscription) +} + +func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { + updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + u := v.User() + prefs := u.Prefs + if prefs == nil || prefs.Subscriptions == nil { + return errHTTPNotFound + } + var subscription *user.Subscription + for _, sub := range prefs.Subscriptions { + if sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic { + sub.DisplayName = updatedSubscription.DisplayName + subscription = sub + break + } + } + if subscription == nil { + return errHTTPNotFound + } + logvr(v, r).Tag(tagAccount).With(subscription).Debug("Changing subscription for user %s", u.Name) + if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil { + return err + } + return s.writeJSON(w, subscription) +} + +func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + // DELETEs cannot have a body, and we don't want it in the path + deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL") + deleteTopic := readParam(r, "X-Topic", "Topic") + u := v.User() + prefs := u.Prefs + if prefs == nil || prefs.Subscriptions == nil { + return nil + } + newSubscriptions := make([]*user.Subscription, 0) + for _, sub := range u.Prefs.Subscriptions { + if sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic { + logvr(v, r).Tag(tagAccount).With(sub).Debug("Removing subscription for user %s", u.Name) + } else { + newSubscriptions = append(newSubscriptions, sub) + } + } + if len(newSubscriptions) < len(prefs.Subscriptions) { + prefs.Subscriptions = newSubscriptions + if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil { + return err + } + } + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountReservationAdd adds a topic reservation for the logged-in user, but only if the user has a tier +// with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless +// it is already reserved by someone else. +func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !topicRegex.MatchString(req.Topic) { + return errHTTPBadRequestTopicInvalid + } + everyone, err := user.ParsePermission(req.Everyone) + if err != nil { + return errHTTPBadRequestPermissionInvalid + } + // Check if we are allowed to reserve this topic + if u.IsUser() && u.Tier == nil { + return errHTTPUnauthorized + } else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil { + return errHTTPConflictTopicReserved + } else if u.IsUser() { + hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic) + if err != nil { + return err + } + if !hasReservation { + reservations, err := s.userManager.ReservationsCount(u.Name) + if err != nil { + return err + } else if reservations >= u.Tier.ReservationLimit { + return errHTTPTooManyRequestsLimitReservations + } + } + } + // Actually add the reservation + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "topic": req.Topic, + "everyone": everyone.String(), + }). + Debug("Adding topic reservation") + if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil { + return err + } + // Kill existing subscribers + t, err := s.topicFromID(req.Topic) + if err != nil { + return err + } + t.CancelSubscribersExceptUser(u.ID) + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountReservationDelete deletes a topic reservation if it is owned by the current user +func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidPath + } + topic := matches[1] + if !topicRegex.MatchString(topic) { + return errHTTPBadRequestTopicInvalid + } + u := v.User() + authorized, err := s.userManager.HasReservation(u.Name, topic) + if err != nil { + return err + } else if !authorized { + return errHTTPUnauthorized + } + deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages") + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "topic": topic, + "delete_messages": deleteMessages, + }). + Debug("Removing topic reservation") + if err := s.userManager.RemoveReservations(u.Name, topic); err != nil { + return err + } + if deleteMessages { + if err := s.messageCache.ExpireMessages(topic); err != nil { + return err + } + s.pruneMessages() + } + return s.writeJSON(w, newSuccessResponse()) +} + +// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier), +// and marks associated messages for the topics as deleted. This also eventually deletes attachments. +// The process relies on the manager to perform the actual deletions (see runManager). +func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error { + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } else if int64(len(reservations)) <= reservationsLimit { + logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove") + return nil + } + topics := make([]string, 0) + for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- { + topics = append(topics, reservations[i].Topic) + } + logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", ")) + if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil { + return err + } + if err := s.messageCache.ExpireMessages(topics...); err != nil { + return err + } + go s.pruneMessages() + return nil +} + +func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } else if req.Channel != "sms" && req.Channel != "call" { + return errHTTPBadRequestPhoneNumberVerifyChannelInvalid + } + // Check user is allowed to add phone numbers + if u == nil || (u.IsUser() && u.Tier == nil) { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier.CallLimit == 0 { + return errHTTPUnauthorized + } + // Check if phone number exists + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } else if util.Contains(phoneNumbers, req.Number) { + return errHTTPConflictPhoneNumberExists + } + // Actually add the unverified number, and send verification + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification") + if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") + if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number") + if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic +func (s *Server) publishSyncEventAsync(v *visitor) { + go func() { + if err := s.publishSyncEvent(v); err != nil { + logv(v).Err(err).Trace("Error publishing to user's sync topic") + } + }() +} + +// publishSyncEvent publishes a sync message to the user's sync topic +func (s *Server) publishSyncEvent(v *visitor) error { + u := v.User() + if u == nil || u.SyncTopic == "" { + return nil + } + logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic") + syncTopic, err := s.topicFromID(u.SyncTopic) + if err != nil { + return err + } + messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent}) + if err != nil { + return err + } + m := newDefaultMessage(syncTopic.ID, string(messageBytes)) + if err := syncTopic.Publish(v, m); err != nil { + return err + } + return nil +} diff --git a/server/server_account_test.go b/server/server_account_test.go new file mode 100644 index 00000000..fd51ac27 --- /dev/null +++ b/server/server_account_test.go @@ -0,0 +1,771 @@ +package server + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "io" + "net/netip" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAccount_Signup_Success(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.NotEmpty(t, token.Token) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires) + require.True(t, strings.HasPrefix(token.Token, "tk_")) + require.Equal(t, "9.9.9.9", token.LastOrigin) + require.True(t, token.LastAccess > time.Now().Unix()-2) + require.True(t, token.LastAccess < time.Now().Unix()+2) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "phil", account.Username) + require.Equal(t, "user", account.Role) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :) + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "phil", account.Username) +} + +func TestAccount_Signup_UserExists(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Signup_LimitReached(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() + + for i := 0; i < 3; i++ { + rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Signup_AsUser(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() + + log.Info("1") + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + log.Info("2") + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + log.Info("3") + rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + log.Info("4") + rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) +} + +func TestAccount_Signup_Disabled(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = false + s := newTestServer(t, conf) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Signup_Rate_Limit(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + for i := 0; i < 3; i++ { + rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) + require.Equal(t, 200, rr.Code, "failed on iteration %d", i) + } + rr := request(t, s, "POST", "/v1/account", `{"username":"notallowed", "password":"mypass"}`, nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Get_Anonymous(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.VisitorRequestLimitReplenish = 86 * time.Second + conf.VisitorEmailLimitReplenish = time.Hour + conf.VisitorAttachmentTotalSizeLimit = 5123 + conf.AttachmentFileSizeLimit = 512 + s := newTestServer(t, conf) + s.smtpSender = &testMailer{} + defer s.closeDatabases() + + rr := request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "*", account.Username) + require.Equal(t, string(user.RoleAnonymous), account.Role) + require.Equal(t, "ip", account.Limits.Basis) + require.Equal(t, int64(1004), account.Limits.Messages) // I hate this + require.Equal(t, int64(24), account.Limits.Emails) // I hate this + require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(512), account.Limits.AttachmentFileSize) + require.Equal(t, int64(0), account.Stats.Messages) + require.Equal(t, int64(1004), account.Stats.MessagesRemaining) + require.Equal(t, int64(0), account.Stats.Emails) + require.Equal(t, int64(24), account.Stats.EmailsRemaining) + require.Equal(t, int64(0), account.Stats.Calls) + require.Equal(t, int64(0), account.Stats.CallsRemaining) + + rr = request(t, s, "POST", "/mytopic", "", nil) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "POST", "/mytopic", "", map[string]string{ + "Email": "phil@ntfy.sh", + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) + require.Equal(t, int64(1002), account.Stats.MessagesRemaining) + require.Equal(t, int64(1), account.Stats.Emails) + require.Equal(t, int64(23), account.Stats.EmailsRemaining) +} + +func TestAccount_ChangeSettings(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + u, _ := s.userManager.User("phil") + token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified()) + + rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"delete_after": 86400}, "language": "de"}`, map[string]string{ + "Authorization": util.BearerAuth(token.Value), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ + "Authorization": util.BearerAuth(token.Value), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "de", account.Language) + require.Equal(t, util.Int(86400), account.Notification.DeleteAfter) + require.Equal(t, util.String("juntos"), account.Notification.Sound) + require.Nil(t, account.Notification.MinPriority) // Not set +} + +func TestAccount_Subscription_AddUpdateDelete(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Subscriptions)) + require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) + require.Equal(t, "def", account.Subscriptions[0].Topic) + require.Nil(t, account.Subscriptions[0].DisplayName) + + rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Subscriptions)) + require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) + require.Equal(t, "def", account.Subscriptions[0].Topic) + require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName) + + rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "X-BaseURL": "http://abc.com", + "X-Topic": "def", + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 0, len(account.Subscriptions)) +} + +func TestAccount_ChangePassword(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + + rr = request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": "new password"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) + + rr = request(t, s, "POST", "/v1/account/password", `{"password": "phil", "new_password": "new password"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 401, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "new password"), + }) + require.Equal(t, 200, rr.Code) +} + +func TestAccount_ChangePassword_NoAccount(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil) + require.Equal(t, 401, rr.Code) +} + +func TestAccount_ExtendToken(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + + time.Sleep(time.Second) + + rr = request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + extendedToken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, token.Token, extendedToken.Token) + require.True(t, token.Expires < extendedToken.Expires) + + expires := time.Now().Add(999 * time.Hour) + body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix()) + rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, "some label", token.Label) + require.Equal(t, expires.Unix(), token.Expires) +} + +func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_DeleteToken(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix()) + + // Delete token failure (using basic auth) + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) + + // Delete token with wrong token + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth("invalidtoken"), + }) + require.Equal(t, 401, rr.Code) + + // Delete token with correct token + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + + // Cannot get account anymore + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 401, rr.Code) +} + +func TestAccount_Delete_Success(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Account was marked deleted + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) + + // Cannot re-create account, since still exists + rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 409, rr.Code) +} + +func TestAccount_Delete_Not_Allowed(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", "", nil) + require.Equal(t, 401, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil) + require.Equal(t, 401, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) +} + +func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + // A user, an admin, and a reservation walk into a bar + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro")) + require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll)) + + require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro")) + + require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin)) + + // Admin can reserve topic + rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "adminpass"), + }) + require.Equal(t, 200, rr.Code) + + // User cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("noadmin2", "pass"), + }) + require.Equal(t, 409, rr.Code) + + // Admin cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "adminpass"), + }) + require.Equal(t, 409, rr.Code) + + reservations, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "sometopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin1") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "mytopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin2") + require.Nil(t, err) + require.Equal(t, 0, len(reservations)) +} + +func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + conf.EnableReservations = true + conf.TwilioAccount = "dummy" + s := newTestServer(t, conf) + + // Create user + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + // Create a tier + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 123, + MessageExpiryDuration: 86400 * time.Second, + EmailLimit: 32, + CallLimit: 10, + ReservationLimit: 2, + AttachmentFileSizeLimit: 1231231, + AttachmentTotalSizeLimit: 123123, + AttachmentExpiryDuration: 10800 * time.Second, + AttachmentBandwidthLimit: 21474836480, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Reserve two topics + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"read-only"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Trying to reserve a third should fail + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "yet-another", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 429, rr.Code) + + // Modify existing should still work + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"write-only"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Check account result + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "pro", account.Tier.Code) + require.Equal(t, int64(123), account.Limits.Messages) + require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) + require.Equal(t, int64(32), account.Limits.Emails) + require.Equal(t, int64(10), account.Limits.Calls) + require.Equal(t, int64(2), account.Limits.Reservations) + require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) + require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth) + require.Equal(t, 2, len(account.Reservations)) + require.Equal(t, "another", account.Reservations[0].Topic) + require.Equal(t, "write-only", account.Reservations[0].Everyone) + require.Equal(t, "mytopic", account.Reservations[1].Topic) + require.Equal(t, "deny-all", account.Reservations[1].Everyone) + + // Delete and re-check + rr = request(t, s, "DELETE", "/v1/account/reservation/another", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Reservations)) + require.Equal(t, "mytopic", account.Reservations[0].Topic) +} + +func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + conf.EnableSignup = true + s := newTestServer(t, conf) + + // Create user with tier + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Reserve a topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Publish a message + rr = request(t, s, "POST", "/mytopic", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Publish a message (as anonymous) + rr = request(t, s, "POST", "/mytopic", `Howdy`, nil) + require.Equal(t, 403, rr.Code) +} + +func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { + t.Parallel() + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + s := newTestServer(t, conf) + + // Create user with tier + require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + MessageExpiryDuration: time.Hour, + ReservationLimit: 2, + AttachmentTotalSizeLimit: 10000, + AttachmentFileSizeLimit: 10000, + AttachmentExpiryDuration: time.Hour, + AttachmentBandwidthLimit: 10000, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Reserve two topics "mytopic1" and "mytopic2" + rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Publish a message with attachment to each topic + rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + m1 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + + rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + m2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) + + // Pre-verify message count and file + ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + + ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) + + // Delete reservation + rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ + "X-Delete-Messages": "true", + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{ + "X-Delete-Messages": "false", + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Verify that messages and attachments were deleted + // This does not explicitly call the manager! + waitFor(t, func() bool { + ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) + require.Nil(t, err) + return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + }) + + ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(ms)) + require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + + ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(ms)) + require.Equal(t, m2.ID, ms[0].ID) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) +} + +/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond + s := newTestServer(t, conf) + defer s.closeDatabases() + + // Create user with tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "starter", + MessageLimit: 10, + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "starter")) + + // Publish a message + rr := request(t, s, "POST", "/mytopic", "hi", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Wait for stats queue writer, verify that message stats were persisted + waitFor(t, func() bool { + u, err := s.userManager.User("phil") + require.Nil(t, err) + return int64(1) == u.Stats.Messages + }) + + // Change tier, make a request (to reset limiters) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(1), account.Stats.Messages) // Is not reset! + + // Publish another message + rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Verify that message stats were persisted + waitFor(t, func() bool { + u, err := s.userManager.User("phil") + require.Nil(t, err) + return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run! + }) + + // Stats keep counting + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) // Is not reset! +}*/ diff --git a/server/server_admin.go b/server/server_admin.go new file mode 100644 index 00000000..5bfd1547 --- /dev/null +++ b/server/server_admin.go @@ -0,0 +1,143 @@ +package server + +import ( + "git.zio.sh/astra/ntfy/v2/user" + "net/http" +) + +func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error { + users, err := s.userManager.Users() + if err != nil { + return err + } + grants, err := s.userManager.AllGrants() + if err != nil { + return err + } + usersResponse := make([]*apiUserResponse, len(users)) + for i, u := range users { + tier := "" + if u.Tier != nil { + tier = u.Tier.Code + } + userGrants := make([]*apiUserGrantResponse, len(grants[u.ID])) + for i, g := range grants[u.ID] { + userGrants[i] = &apiUserGrantResponse{ + Topic: g.TopicPattern, + Permission: g.Allow.String(), + } + } + usersResponse[i] = &apiUserResponse{ + Username: u.Name, + Role: string(u.Role), + Tier: tier, + Grants: userGrants, + } + } + return s.writeJSON(w, usersResponse) +} + +func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !user.AllowedUsername(req.Username) || req.Password == "" { + return errHTTPBadRequest.Wrap("username invalid, or password missing") + } + u, err := s.userManager.User(req.Username) + if err != nil && err != user.ErrUserNotFound { + return err + } else if u != nil { + return errHTTPConflictUserExists + } + var tier *user.Tier + if req.Tier != "" { + tier, err = s.userManager.Tier(req.Tier) + if err == user.ErrTierNotFound { + return errHTTPBadRequestTierInvalid + } else if err != nil { + return err + } + } + if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil { + return err + } + if tier != nil { + if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil { + return err + } + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + u, err := s.userManager.User(req.Username) + if err == user.ErrUserNotFound { + return errHTTPBadRequestUserNotFound + } else if err != nil { + return err + } else if !u.IsUser() { + return errHTTPUnauthorized.Wrap("can only remove regular users from API") + } + if err := s.userManager.RemoveUser(req.Username); err != nil { + return err + } + if err := s.killUserSubscriber(u, "*"); err != nil { // FIXME super inefficient + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + _, err = s.userManager.User(req.Username) + if err == user.ErrUserNotFound { + return errHTTPBadRequestUserNotFound + } else if err != nil { + return err + } + permission, err := user.ParsePermission(req.Permission) + if err != nil { + return errHTTPBadRequestPermissionInvalid + } + if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + u, err := s.userManager.User(req.Username) + if err != nil { + return err + } + if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil { + return err + } + if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error { + topics, err := s.topicsFromPattern(topicPattern) + if err != nil { + return err + } + for _, t := range topics { + t.CancelSubscriberUser(u.ID) + } + return nil +} diff --git a/server/server_admin_test.go b/server/server_admin_test.go new file mode 100644 index 00000000..a2a6f432 --- /dev/null +++ b/server/server_admin_test.go @@ -0,0 +1,181 @@ +package server + +import ( + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "sync/atomic" + "testing" + "time" +) + +func TestUser_AddRemove(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin, tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier1", + })) + + // Create user via API + rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Create user with tier via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check users + users, err := s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 4, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, user.RoleUser, users[1].Role) + require.Nil(t, users[1].Tier) + require.Equal(t, "emma", users[2].Name) + require.Equal(t, user.RoleUser, users[2].Role) + require.Equal(t, "tier1", users[2].Tier.Code) + require.Equal(t, user.Everyone, users[3].Name) + + // Delete user via API + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) +} + +func TestUser_AddRemove_Failures(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + + // Cannot create user with invalid username + rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + + // Cannot create user if user already exists + rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) + + // Cannot create user with invalid tier + rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code) + + // Cannot delete user as non-admin + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Delete user via API + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) +} + +func TestAccess_AllowReset(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User and admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + + // Subscribing not allowed + rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, rr.Code) + + // Grant access + rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Now subscribing is allowed + rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + + // Reset access + rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Subscribing not allowed (again) + rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, rr.Code) +} + +func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + + // Grant access fails, because non-admin + rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) +} + +func TestAccess_AllowReset_KillConnection(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User and admin, grant access to "gol*" topics + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard! + + start, timeTaken := time.Now(), atomic.Int64{} + go func() { + rr := request(t, s, "GET", "/gold/json", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + timeTaken.Store(time.Since(start).Milliseconds()) + }() + time.Sleep(500 * time.Millisecond) + + // Reset access + rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Wait for connection to be killed; this will fail if the connection is never killed + waitFor(t, func() bool { + return timeTaken.Load() >= 500 + }) +} diff --git a/server/server_firebase.go b/server/server_firebase.go index 384e7116..eb1db971 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -3,18 +3,201 @@ package server import ( "context" "encoding/json" - firebase "firebase.google.com/go" - "firebase.google.com/go/messaging" + "errors" + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/messaging" "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" "google.golang.org/api/option" - "heckel.io/ntfy/auth" "strings" ) const ( - fcmMessageLimit = 4000 + fcmMessageLimit = 4000 + fcmApnsBodyMessageLimit = 100 ) +var ( + errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic") + errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase") +) + +// firebaseClient is a generic client that formats and sends messages to Firebase. +// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable. +type firebaseClient struct { + sender firebaseSender + auther user.Auther +} + +func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient { + return &firebaseClient{ + sender: sender, + auther: auther, + } +} + +func (c *firebaseClient) Send(v *visitor, m *message) error { + if !v.FirebaseAllowed() { + return errFirebaseTemporarilyBanned + } + fbm, err := toFirebaseMessage(m, c.auther) + if err != nil { + return err + } + ev := logvm(v, m).Tag(tagFirebase) + if ev.IsTrace() { + ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message") + } + err = c.sender.Send(fbm) + if err == errFirebaseQuotaExceeded { + logvm(v, m). + Tag(tagFirebase). + Err(err). + Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor") + v.FirebaseTemporarilyDeny() + } + return err +} + +// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging. +// In tests, this can be implemented with a mock. +type firebaseSender interface { + // Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded + // if a rate limit has reached. + Send(m *messaging.Message) error +} + +// firebaseSenderImpl is a firebaseSender that actually talks to Firebase +type firebaseSenderImpl struct { + client *messaging.Client +} + +func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) { + fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) + if err != nil { + return nil, err + } + client, err := fb.Messaging(context.Background()) + if err != nil { + return nil, err + } + return &firebaseSenderImpl{ + client: client, + }, nil +} + +func (c *firebaseSenderImpl) Send(m *messaging.Message) error { + _, err := c.client.Send(context.Background(), m) + if err != nil && messaging.IsQuotaExceeded(err) { + return errFirebaseQuotaExceeded + } + return err +} + +// toFirebaseMessage converts a message to a Firebase message. +// +// Normal messages ("message"): +// - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields +// in the "data" attribute. In the Android app, we then turn those into a notification and display it. +// - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and +// message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the +// message in the Notification Service Extension. +// +// Keepalive messages ("keepalive"): +// - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died, +// e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval). +// - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS +// does not allow any background or scheduled activity at all. +// +// Poll request messages ("poll_request"): +// - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message. +// On Android, this will trigger the app to poll the topic and thereby displaying new messages. +// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded +// to Firebase here. This is mainly for iOS to support self-hosted servers. +func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) { + var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format + var apnsConfig *messaging.APNSConfig + switch m.Event { + case keepaliveEvent, openEvent: + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + } + apnsConfig = createAPNSBackgroundConfig(data) + case pollRequestEvent: + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + "message": m.Message, + "poll_id": m.PollID, + } + apnsConfig = createAPNSAlertConfig(m, data) + case messageEvent: + allowForward := true + if auther != nil { + allowForward = auther.Authorize(nil, m.Topic, user.PermissionRead) == nil + } + if allowForward { + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + "priority": fmt.Sprintf("%d", m.Priority), + "tags": strings.Join(m.Tags, ","), + "click": m.Click, + "icon": m.Icon, + "title": m.Title, + "message": m.Message, + "content_type": m.ContentType, + "encoding": m.Encoding, + } + if len(m.Actions) > 0 { + actions, err := json.Marshal(m.Actions) + if err != nil { + return nil, err + } + data["actions"] = string(actions) + } + if m.Attachment != nil { + data["attachment_name"] = m.Attachment.Name + data["attachment_type"] = m.Attachment.Type + data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size) + data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) + data["attachment_url"] = m.Attachment.URL + } + apnsConfig = createAPNSAlertConfig(m, data) + } else { + // If anonymous read for a topic is not allowed, we cannot send the message along + // via Firebase. Instead, we send a "poll_request" message, asking the client to poll. + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": pollRequestEvent, + "topic": m.Topic, + } + // TODO Handle APNS? + } + } + var androidConfig *messaging.AndroidConfig + if m.Priority >= 4 { + androidConfig = &messaging.AndroidConfig{ + Priority: "high", + } + } + return maybeTruncateFCMMessage(&messaging.Message{ + Topic: m.Topic, + Data: data, + Android: androidConfig, + APNS: apnsConfig, + }), nil +} + // maybeTruncateFCMMessage performs best-effort truncation of FCM messages. // The docs say the limit is 4000 characters, but during testing it wasn't quite clear // what fields matter; so we're just capping the serialized JSON to 4000 bytes. @@ -34,87 +217,62 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message { return m } -func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) { - fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) - if err != nil { - return nil, err +// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS). +// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service +// Extension in iOS can modify the message. +func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig { + apnsData := make(map[string]any) + for k, v := range data { + apnsData[k] = v } - msg, err := fb.Messaging(context.Background()) - if err != nil { - return nil, err + return &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + CustomData: apnsData, + Aps: &messaging.Aps{ + MutableContent: true, + Alert: &messaging.ApsAlert{ + Title: m.Title, + Body: maybeTruncateAPNSBodyMessage(m.Message), + }, + }, + }, } - return func(m *message) error { - fbm, err := toFirebaseMessage(m, auther) - if err != nil { - return err - } - _, err = msg.Send(context.Background(), fbm) - return err - }, nil } -func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) { - var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format - switch m.Event { - case keepaliveEvent, openEvent: - data = map[string]string{ - "id": m.ID, - "time": fmt.Sprintf("%d", m.Time), - "event": m.Event, - "topic": m.Topic, - } - case messageEvent: - allowForward := true - if auther != nil { - allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil - } - if allowForward { - data = map[string]string{ - "id": m.ID, - "time": fmt.Sprintf("%d", m.Time), - "event": m.Event, - "topic": m.Topic, - "priority": fmt.Sprintf("%d", m.Priority), - "tags": strings.Join(m.Tags, ","), - "click": m.Click, - "title": m.Title, - "message": m.Message, - "encoding": m.Encoding, - } - if len(m.Actions) > 0 { - actions, err := json.Marshal(m.Actions) - if err != nil { - return nil, err - } - data["actions"] = string(actions) - } - if m.Attachment != nil { - data["attachment_name"] = m.Attachment.Name - data["attachment_type"] = m.Attachment.Type - data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size) - data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) - data["attachment_url"] = m.Attachment.URL - } - } else { - // If anonymous read for a topic is not allowed, we cannot send the message along - // via Firebase. Instead, we send a "poll_request" message, asking the client to poll. - data = map[string]string{ - "id": m.ID, - "time": fmt.Sprintf("%d", m.Time), - "event": pollRequestEvent, - "topic": m.Topic, - } - } +// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only +// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll +// topic, which triggers the iOS app to poll all topics for changes. +// +// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app +func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig { + apnsData := make(map[string]any) + for k, v := range data { + apnsData[k] = v } - var androidConfig *messaging.AndroidConfig - if m.Priority >= 4 { - androidConfig = &messaging.AndroidConfig{ - Priority: "high", - } + return &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-push-type": "background", + "apns-priority": "5", + }, + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + ContentAvailable: true, + }, + CustomData: apnsData, + }, } - return maybeTruncateFCMMessage(&messaging.Message{ - Topic: m.Topic, - Data: data, - Android: androidConfig, - }), nil +} + +// maybeTruncateAPNSBodyMessage truncates the body for APNS. +// +// The "body" of the push notification can contain the entire message, which would count doubly for the overall length +// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis. +// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB), +// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device. +func maybeTruncateAPNSBodyMessage(s string) string { + if len(s) >= fcmApnsBodyMessageLimit { + over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...") + return s[:len(s)-over] + "..." + } + return s } diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index 1fdd8a6e..ca1147e3 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -3,35 +3,86 @@ package server import ( "encoding/json" "errors" - "firebase.google.com/go/messaging" "fmt" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/auth" + "git.zio.sh/astra/ntfy/v2/user" + "net/netip" "strings" + "sync" "testing" + + "firebase.google.com/go/v4/messaging" + "github.com/stretchr/testify/require" ) type testAuther struct { Allow bool } -func (t testAuther) Authenticate(_, _ string) (*auth.User, error) { +var _ user.Auther = (*testAuther)(nil) + +func (t testAuther) Authenticate(_, _ string) (*user.User, error) { return nil, errors.New("not used") } -func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error { +func (t testAuther) Authorize(_ *user.User, _ string, _ user.Permission) error { if t.Allow { return nil } return errors.New("unauthorized") } +type testFirebaseSender struct { + allowed int + messages []*messaging.Message + mu sync.Mutex +} + +func newTestFirebaseSender(allowed int) *testFirebaseSender { + return &testFirebaseSender{ + allowed: allowed, + messages: make([]*messaging.Message, 0), + } +} + +func (s *testFirebaseSender) Send(m *messaging.Message) error { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.messages)+1 > s.allowed { + return errFirebaseQuotaExceeded + } + s.messages = append(s.messages, m) + return nil +} + +func (s *testFirebaseSender) Messages() []*messaging.Message { + s.mu.Lock() + defer s.mu.Unlock() + return append(make([]*messaging.Message, 0), s.messages...) +} + func TestToFirebaseMessage_Keepalive(t *testing.T) { m := newKeepaliveMessage("mytopic") fbm, err := toFirebaseMessage(m, nil) require.Nil(t, err) require.Equal(t, "mytopic", fbm.Topic) require.Nil(t, fbm.Android) + require.Equal(t, &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-push-type": "background", + "apns-priority": "5", + }, + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + ContentAvailable: true, + }, + CustomData: map[string]any{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + }, + }, + }, fbm.APNS) require.Equal(t, map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -46,6 +97,23 @@ func TestToFirebaseMessage_Open(t *testing.T) { require.Nil(t, err) require.Equal(t, "mytopic", fbm.Topic) require.Nil(t, fbm.Android) + require.Equal(t, &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-push-type": "background", + "apns-priority": "5", + }, + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + ContentAvailable: true, + }, + CustomData: map[string]any{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + }, + }, + }, fbm.APNS) require.Equal(t, map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -59,14 +127,33 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { m.Priority = 4 m.Tags = []string{"tag 1", "tag2"} m.Click = "https://google.com" + m.Icon = "https://ntfy.sh/static/img/ntfy.png" m.Title = "some title" + m.Actions = []*action{ + { + ID: "123", + Action: "view", + Label: "Open page", + Clear: true, + URL: "https://ntfy.sh", + }, + { + ID: "456", + Action: "http", + Label: "Close door", + URL: "https://door.com/close", + Method: "PUT", + Headers: map[string]string{ + "really": "yes", + }, + }, + } m.Attachment = &attachment{ Name: "some file.jpg", Type: "image/jpeg", Size: 12345, Expires: 98765543, URL: "https://example.com/file.jpg", - Owner: "some-owner", } fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) require.Nil(t, err) @@ -74,6 +161,37 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { require.Equal(t, &messaging.AndroidConfig{ Priority: "high", }, fbm.Android) + require.Equal(t, &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + MutableContent: true, + Alert: &messaging.ApsAlert{ + Title: "some title", + Body: "this is a message", + }, + }, + CustomData: map[string]any{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": "message", + "topic": "mytopic", + "priority": "4", + "tags": strings.Join(m.Tags, ","), + "click": "https://google.com", + "icon": "https://ntfy.sh/static/img/ntfy.png", + "title": "some title", + "message": "this is a message", + "actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`, + "content_type": "", + "encoding": "", + "attachment_name": "some file.jpg", + "attachment_type": "image/jpeg", + "attachment_size": "12345", + "attachment_expires": "98765543", + "attachment_url": "https://example.com/file.jpg", + }, + }, + }, fbm.APNS) require.Equal(t, map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -82,8 +200,11 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", + "icon": "https://ntfy.sh/static/img/ntfy.png", "title": "some title", "message": "this is a message", + "actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`, + "content_type": "", "encoding": "", "attachment_name": "some file.jpg", "attachment_type": "image/jpeg", @@ -112,6 +233,41 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { }, fbm.Data) } +func TestToFirebaseMessage_PollRequest(t *testing.T) { + m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6") + fbm, err := toFirebaseMessage(m, nil) + require.Nil(t, err) + require.Equal(t, "mytopic", fbm.Topic) + require.Nil(t, fbm.Android) + require.Equal(t, &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + MutableContent: true, + Alert: &messaging.ApsAlert{ + Title: "", + Body: "New message", + }, + }, + CustomData: map[string]any{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": "poll_request", + "topic": "mytopic", + "message": "New message", + "poll_id": "fOv6k1QbCzo6", + }, + }, + }, fbm.APNS) + require.Equal(t, map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": "poll_request", + "topic": "mytopic", + "message": "New message", + "poll_id": "fOv6k1QbCzo6", + }, fbm.Data) +} + func TestMaybeTruncateFCMMessage(t *testing.T) { origMessage := strings.Repeat("this is a long string", 300) origFCMMessage := &messaging.Message{ @@ -168,3 +324,22 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) } + +func TestToFirebaseSender_Abuse(t *testing.T) { + sender := &testFirebaseSender{allowed: 2} + client := newFirebaseClient(sender, &testAuther{}) + visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil) + + require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, 1, len(sender.Messages())) + + require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, 2, len(sender.Messages())) + + require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, 2, len(sender.Messages())) + + sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working + require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, 0, len(sender.Messages())) +} diff --git a/server/server_manager.go b/server/server_manager.go new file mode 100644 index 00000000..7a562a94 --- /dev/null +++ b/server/server_manager.go @@ -0,0 +1,192 @@ +package server + +import ( + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "strings" +) + +func (s *Server) execManager() { + // WARNING: Make sure to only selectively lock with the mutex, and be aware that this + // there is no mutex for the entire function. + + // Prune all the things + s.pruneVisitors() + s.pruneTokens() + s.pruneAttachments() + s.pruneMessages() + s.pruneAndNotifyWebPushSubscriptions() + + // Message count per topic + var messagesCached int + messageCounts, err := s.messageCache.MessageCounts() + if err != nil { + log.Tag(tagManager).Err(err).Warn("Cannot get message counts") + messageCounts = make(map[string]int) // Empty, so we can continue + } + for _, count := range messageCounts { + messagesCached += count + } + + // Remove subscriptions without subscribers + var emptyTopics, subscribers int + log. + Tag(tagManager). + Timing(func() { + s.mu.Lock() + defer s.mu.Unlock() + for _, t := range s.topics { + subs, lastAccess := t.Stats() + ev := log.Tag(tagManager).With(t) + if t.Stale() { + if ev.IsTrace() { + ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess)) + } + emptyTopics++ + delete(s.topics, t.ID) + } else { + if ev.IsTrace() { + ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess)) + } + subscribers += subs + } + } + }). + Debug("Removed %d empty topic(s)", emptyTopics) + + // Mail stats + var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64 + if s.smtpServerBackend != nil { + receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() + } + var sentMailTotal, sentMailSuccess, sentMailFailure int64 + if s.smtpSender != nil { + sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts() + } + + // Users + var usersCount int64 + if s.userManager != nil { + usersCount, err = s.userManager.UsersCount() + if err != nil { + log.Tag(tagManager).Err(err).Warn("Error counting users") + } + } + + // Print stats + s.mu.RLock() + messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors) + s.mu.RUnlock() + + // Update stats + s.updateAndWriteStats(messagesCount) + + // Log stats + log. + Tag(tagManager). + Fields(log.Context{ + "messages_published": messagesCount, + "messages_cached": messagesCached, + "topics_active": topicsCount, + "subscribers": subscribers, + "visitors": visitorsCount, + "users": usersCount, + "emails_received": receivedMailTotal, + "emails_received_success": receivedMailSuccess, + "emails_received_failure": receivedMailFailure, + "emails_sent": sentMailTotal, + "emails_sent_success": sentMailSuccess, + "emails_sent_failure": sentMailFailure, + }). + Info("Server stats") + mset(metricMessagesCached, messagesCached) + mset(metricVisitors, visitorsCount) + mset(metricUsers, usersCount) + mset(metricSubscribers, subscribers) + mset(metricTopics, topicsCount) +} + +func (s *Server) pruneVisitors() { + staleVisitors := 0 + log. + Tag(tagManager). + Timing(func() { + s.mu.Lock() + defer s.mu.Unlock() + for ip, v := range s.visitors { + if v.Stale() { + log.Tag(tagManager).With(v).Trace("Deleting stale visitor") + delete(s.visitors, ip) + staleVisitors++ + } + } + }). + Field("stale_visitors", staleVisitors). + Debug("Deleted %d stale visitor(s)", staleVisitors) +} + +func (s *Server) pruneTokens() { + if s.userManager != nil { + log. + Tag(tagManager). + Timing(func() { + if err := s.userManager.RemoveExpiredTokens(); err != nil { + log.Tag(tagManager).Err(err).Warn("Error expiring user tokens") + } + if err := s.userManager.RemoveDeletedUsers(); err != nil { + log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users") + } + }). + Debug("Removed expired tokens and users") + } +} + +func (s *Server) pruneAttachments() { + if s.fileCache == nil { + return + } + log. + Tag(tagManager). + Timing(func() { + ids, err := s.messageCache.AttachmentsExpired() + if err != nil { + log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") + } else if len(ids) > 0 { + if log.Tag(tagManager).IsDebug() { + log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) + } + if err := s.fileCache.Remove(ids...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error deleting attachments") + } + if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") + } + } else { + log.Tag(tagManager).Debug("No expired attachments to delete") + } + }). + Debug("Deleted expired attachments") +} + +func (s *Server) pruneMessages() { + log. + Tag(tagManager). + Timing(func() { + expiredMessageIDs, err := s.messageCache.MessagesExpired() + if err != nil { + log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") + } else if len(expiredMessageIDs) > 0 { + if s.fileCache != nil { + if err := s.fileCache.Remove(expiredMessageIDs...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") + } + } + if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") + } + } else { + log.Tag(tagManager).Debug("No expired messages to delete") + } + }). + Debug("Pruned messages") +} diff --git a/server/server_manager_test.go b/server/server_manager_test.go new file mode 100644 index 00000000..f17d583f --- /dev/null +++ b/server/server_manager_test.go @@ -0,0 +1,28 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) { + // Tests that the manager runs without attachment-cache-dir set, see #617 + c := newTestConfig(t) + c.AttachmentCacheDir = "" + s := newTestServer(t, c) + + // Publish a message + rr := request(t, s, "POST", "/mytopic", "hi", nil) + require.Equal(t, 200, rr.Code) + m := toMessage(t, rr.Body.String()) + + // Expire message + require.Nil(t, s.messageCache.ExpireMessages("mytopic")) + + // Does not panic + s.pruneMessages() + + // Actually deleted + _, err := s.messageCache.Message(m.ID) + require.Equal(t, errMessageNotFound, err) +} diff --git a/server/server_matrix.go b/server/server_matrix.go new file mode 100644 index 00000000..bf43a13f --- /dev/null +++ b/server/server_matrix.go @@ -0,0 +1,172 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "io" + "net/http" + "strings" + "time" +) + +// Matrix Push Gateway / UnifiedPush / ntfy integration: +// +// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/), +// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/). +// +// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's +// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the +// ntfy Android app. +// +// +--------------------+ +-------------------+ +// Matrix HTTP | | | | +// Notification Protocol | App Developer | | Device Vendor | +// | | | | +// +-------------------+ | +----------------+ | | +---------------+ | +// | | | | | | | | | | +// | Matrix homeserver +-----> Push Gateway +------> Push Provider | | +// | | | | | | | | | | +// +-^-----------------+ | +----------------+ | | +----+----------+ | +// | | | | | | +// Matrix | | | | | | +// Client/Server API + | | | | | +// | | +--------------------+ +-------------------+ +// | +--+-+ | +// | | <-------------------------------------------+ +// +---+ | +// | | Provider Push Protocol +// +----+ +// +// Mobile Device or Client +// + +// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per +// this spec: https://spec.matrix.org/v1.2/push-gateway-api/). +// +// From the message, we only require the "pushkey", as it represents our target topic URL. +// A message may look like this (excerpt): +// +// { +// "notification": { +// "devices": [ +// { +// "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", +// ... +// } +// ] +// } +// } +type matrixRequest struct { + Notification *struct { + Devices []*struct { + PushKey string `json:"pushkey"` + } `json:"devices"` + } `json:"notification"` +} + +// matrixResponse represents the response to a Matrix push gateway message, as defined +// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/). +type matrixResponse struct { + Rejected []string `json:"rejected"` +} + +const ( + // matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response + // will return an HTTP 200 with the push key (i.e. "rejected":[""]}), if no rate visitor has been set on + // the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending + // messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/ + matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour +) + +// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages +// +// If the push key is set, the app server will remove it and will never send messages using the same +// push key again, until the user repairs it. +type errMatrixPushkeyRejected struct { + rejectedPushKey string + configuredBaseURL string +} + +func (e errMatrixPushkeyRejected) Error() string { + return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL) +} + +// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new +// HTTP request that looks like a normal ntfy request from it. +// +// It basically converts a Matrix push gatewqy request: +// +// POST /_matrix/push/v1/notify HTTP/1.1 +// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } } +// +// to a ntfy request, looking like this: +// +// POST /upDAHJKFFDFD?up=1 HTTP/1.1 +// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } } +func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) { + if baseURL == "" { + return nil, errHTTPInternalErrorMissingBaseURL + } + body, err := util.Peek(r.Body, messageLimit) + if err != nil { + return nil, err + } + defer r.Body.Close() + if body.LimitReached { + return nil, errHTTPEntityTooLargeMatrixRequest + } + var m matrixRequest + if err := json.Unmarshal(body.PeekedBytes, &m); err != nil { + return nil, errHTTPBadRequestMatrixMessageInvalid + } else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" { + return nil, errHTTPBadRequestMatrixMessageInvalid + } + pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316 + if !strings.HasPrefix(pushKey, baseURL+"/") { + return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL} + } + newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes))) + if err != nil { + return nil, err + } + newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted + if r.Header.Get("X-Forwarded-For") != "" { + newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + } + newRequest = withContext(newRequest, map[contextKey]any{ + contextMatrixPushKey: pushKey, + }) + return newRequest, nil +} + +// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter, +// as per the spec (https://unifiedpush.org/developers/gateway/). +func writeMatrixDiscoveryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n") + return err +} + +// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter +func writeMatrixSuccess(w http.ResponseWriter) error { + return writeMatrixResponse(w, "") +} + +// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in +// the spec (https://spec.matrix.org/v1.2/push-gateway-api/) +func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error { + rejected := make([]string, 0) + if rejectedPushKey != "" { + rejected = append(rejected, rejectedPushKey) + } + response := &matrixResponse{ + Rejected: rejected, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil +} diff --git a/server/server_matrix_test.go b/server/server_matrix_test.go new file mode 100644 index 00000000..e723ac03 --- /dev/null +++ b/server/server_matrix_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + newRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Nil(t, err) + require.Equal(t, "POST", newRequest.Method) + require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String()) + require.Equal(t, body, readAll(t, newRequest.Body)) +} + +func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 10 // Small + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPEntityTooLargeMatrixRequest, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `this is not json` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) { + baseURL := "https://ntfy.sh" + maxLength := 4096 + body := `{"message":"this is not a matrix message, but valid json"}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err) +} + +func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) { + baseURL := "https://ntfy.sh" // Mismatch! + maxLength := 4096 + body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) + _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) + matrixErr, ok := err.(*errMatrixPushkeyRejected) + require.True(t, ok) + require.Equal(t, "push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.Error()) + require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey) +} + +func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) { + w := httptest.NewRecorder() + require.Nil(t, writeMatrixDiscoveryResponse(w)) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", w.Body.String()) +} + +func TestMatrix_WriteMatrixError(t *testing.T) { + w := httptest.NewRecorder() + require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1")) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String()) +} + +func TestMatrix_WriteMatrixSuccess(t *testing.T) { + w := httptest.NewRecorder() + require.Nil(t, writeMatrixSuccess(w)) + require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, `{"rejected":[]}`+"\n", w.Body.String()) +} diff --git a/server/server_metrics.go b/server/server_metrics.go new file mode 100644 index 00000000..88fa9f15 --- /dev/null +++ b/server/server_metrics.go @@ -0,0 +1,132 @@ +package server + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +var ( + metricMessagesPublishedSuccess prometheus.Counter + metricMessagesPublishedFailure prometheus.Counter + metricMessagesCached prometheus.Gauge + metricMessagePublishDurationMillis prometheus.Gauge + metricFirebasePublishedSuccess prometheus.Counter + metricFirebasePublishedFailure prometheus.Counter + metricEmailsPublishedSuccess prometheus.Counter + metricEmailsPublishedFailure prometheus.Counter + metricEmailsReceivedSuccess prometheus.Counter + metricEmailsReceivedFailure prometheus.Counter + metricCallsMadeSuccess prometheus.Counter + metricCallsMadeFailure prometheus.Counter + metricUnifiedPushPublishedSuccess prometheus.Counter + metricMatrixPublishedSuccess prometheus.Counter + metricMatrixPublishedFailure prometheus.Counter + metricAttachmentsTotalSize prometheus.Gauge + metricVisitors prometheus.Gauge + metricSubscribers prometheus.Gauge + metricTopics prometheus.Gauge + metricUsers prometheus.Gauge + metricHTTPRequests *prometheus.CounterVec +) + +func initMetrics() { + metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_messages_published_success", + }) + metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_messages_published_failure", + }) + metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_messages_cached_total", + }) + metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_message_publish_duration_ms", + }) + metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_firebase_published_success", + }) + metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_firebase_published_failure", + }) + metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_emails_sent_success", + }) + metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_emails_sent_failure", + }) + metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_emails_received_success", + }) + metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_emails_received_failure", + }) + metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_success", + }) + metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_failure", + }) + metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_unifiedpush_published_success", + }) + metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_matrix_published_success", + }) + metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_matrix_published_failure", + }) + metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_attachments_total_size", + }) + metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_visitors_total", + }) + metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_users_total", + }) + metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_subscribers_total", + }) + metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "ntfy_topics_total", + }) + metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ntfy_http_requests_total", + }, []string{"http_code", "ntfy_code", "http_method"}) + prometheus.MustRegister( + metricMessagesPublishedSuccess, + metricMessagesPublishedFailure, + metricMessagesCached, + metricMessagePublishDurationMillis, + metricFirebasePublishedSuccess, + metricFirebasePublishedFailure, + metricEmailsPublishedSuccess, + metricEmailsPublishedFailure, + metricEmailsReceivedSuccess, + metricEmailsReceivedFailure, + metricCallsMadeSuccess, + metricCallsMadeFailure, + metricUnifiedPushPublishedSuccess, + metricMatrixPublishedSuccess, + metricMatrixPublishedFailure, + metricAttachmentsTotalSize, + metricVisitors, + metricUsers, + metricSubscribers, + metricTopics, + metricHTTPRequests, + ) +} + +// minc increments a prometheus.Counter if it is non-nil +func minc(counter prometheus.Counter) { + if counter != nil { + counter.Inc() + } +} + +// mset sets a prometheus.Gauge if it is non-nil +func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) { + if gauge != nil { + gauge.Set(float64(value)) + } +} diff --git a/server/server_middleware.go b/server/server_middleware.go new file mode 100644 index 00000000..5d842b98 --- /dev/null +++ b/server/server_middleware.go @@ -0,0 +1,132 @@ +package server + +import ( + "net/http" + + "git.zio.sh/astra/ntfy/v2/util" +) + +type contextKey int + +const ( + contextRateVisitor contextKey = iota + 2586 + contextTopic + contextMatrixPushKey +) + +func (s *Server) limitRequests(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) { + return next(w, r, v) + } else if !v.RequestAllowed() { + return errHTTPTooManyRequestsLimitRequests + } + return next(w, r, v) + } +} + +// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context +func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := s.topicFromPath(r.URL.Path) + if err != nil { + return err + } + vrate := v + if rateVisitor := t.RateVisitor(); rateVisitor != nil { + vrate = rateVisitor + } + r = withContext(r, map[contextKey]any{ + contextRateVisitor: vrate, + contextTopic: t, + }) + if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) { + return next(w, r, v) + } else if !vrate.RequestAllowed() { + return errHTTPTooManyRequestsLimitRequests + } + return next(w, r, v) + } +} + +func (s *Server) ensureWebEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.WebRoot == "" { + return errHTTPNotFound + } + return next(w, r, v) + } +} + +func (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.WebRoot == "" || s.config.WebPushPublicKey == "" { + return errHTTPNotFound + } + return next(w, r, v) + } +} + +func (s *Server) ensureUserManager(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.userManager == nil { + return errHTTPNotFound + } + return next(w, r, v) + } +} + +func (s *Server) ensureUser(next handleFunc) handleFunc { + return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if v.User() == nil { + return errHTTPUnauthorized + } + return next(w, r, v) + }) +} + +func (s *Server) ensureAdmin(next handleFunc) handleFunc { + return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if !v.User().IsAdmin() { + return errHTTPUnauthorized + } + return next(w, r, v) + }) +} + +func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.TwilioAccount == "" || s.userManager == nil { + return errHTTPNotFound + } + return next(w, r, v) + } +} + +func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.StripeSecretKey == "" || s.stripe == nil { + return errHTTPNotFound + } + return next(w, r, v) + } +} + +func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc { + return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if v.User().Billing.StripeCustomerID == "" { + return errHTTPBadRequestNotAPaidUser + } + return next(w, r, v) + }) +} + +func (s *Server) withAccountSync(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + err := next(w, r, v) + if err == nil { + s.publishSyncEventAsync(v) + } + return err + } +} diff --git a/server/server_payments.go b/server/server_payments.go new file mode 100644 index 00000000..a4b51a11 --- /dev/null +++ b/server/server_payments.go @@ -0,0 +1,564 @@ +package server + +import ( + "bytes" + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stripe/stripe-go/v74" + portalsession "github.com/stripe/stripe-go/v74/billingportal/session" + "github.com/stripe/stripe-go/v74/checkout/session" + "github.com/stripe/stripe-go/v74/customer" + "github.com/stripe/stripe-go/v74/price" + "github.com/stripe/stripe-go/v74/subscription" + "github.com/stripe/stripe-go/v74/webhook" + "io" + "net/http" + "net/netip" + "time" +) + +// Payments in ntfy are done via Stripe. +// +// Pretty much all payments related things are in this file. The following processes +// handle payments: +// +// - Checkout: +// Creating a Stripe customer and subscription via the Checkout flow. This flow is only used if the +// ntfy user is not already a Stripe customer. This requires redirecting to the Stripe checkout page. +// It is implemented in handleAccountBillingSubscriptionCreate and the success callback +// handleAccountBillingSubscriptionCreateSuccess. +// - Update subscription: +// Switching between Stripe subscriptions (upgrade/downgrade) is handled via +// handleAccountBillingSubscriptionUpdate. This also handles proration. +// - Cancel subscription (at period end): +// Users can cancel the Stripe subscription via the web app at the end of the billing period. This +// simply updates the subscription and Stripe will cancel it. Users cannot immediately cancel the +// subscription. +// - Webhooks: +// Whenever a subscription changes (updated, deleted), Stripe sends us a request via a webhook. +// This is used to keep the local user database fields up to date. Stripe is the source of truth. +// What Stripe says is mirrored and not questioned. + +var ( + errNotAPaidTier = errors.New("tier does not have billing price identifier") + errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions") + errNoBillingSubscription = errors.New("user does not have an active billing subscription") +) + +var ( + retryUserDelays = []time.Duration{3 * time.Second, 5 * time.Second, 7 * time.Second} +) + +// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog +// in the UI. Note that this endpoint does NOT have a user context (no u!). +func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + tiers, err := s.userManager.Tiers() + if err != nil { + return err + } + freeTier := configBasedVisitorLimits(s.config) + response := []*apiAccountBillingTier{ + { + // This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price. + Limits: &apiAccountLimits{ + Basis: string(visitorLimitBasisIP), + Messages: freeTier.MessageLimit, + MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), + Emails: freeTier.EmailLimit, + Calls: freeTier.CallLimit, + Reservations: freeTier.ReservationsLimit, + AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, + AttachmentFileSize: freeTier.AttachmentFileSizeLimit, + AttachmentExpiryDuration: int64(freeTier.AttachmentExpiryDuration.Seconds()), + }, + }, + } + prices, err := s.priceCache.Value() + if err != nil { + return err + } + for _, tier := range tiers { + priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID] + if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices! + continue + } + response = append(response, &apiAccountBillingTier{ + Code: tier.Code, + Name: tier.Name, + Prices: &apiAccountBillingPrices{ + Month: priceMonth, + Year: priceYear, + }, + Limits: &apiAccountLimits{ + Basis: string(visitorLimitBasisTier), + Messages: tier.MessageLimit, + MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), + Emails: tier.EmailLimit, + Calls: tier.CallLimit, + Reservations: tier.ReservationLimit, + AttachmentTotalSize: tier.AttachmentTotalSizeLimit, + AttachmentFileSize: tier.AttachmentFileSizeLimit, + AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()), + }, + }) + } + return s.writeJSON(w, response) +} + +// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier +// will be updated by a subsequent webhook from Stripe, once the subscription becomes active. +func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + if u.Billing.StripeSubscriptionID != "" { + return errHTTPBadRequestBillingSubscriptionExists + } + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + tier, err := s.userManager.Tier(req.Tier) + if err != nil { + return err + } + var priceID string + if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { + priceID = tier.StripeMonthlyPriceID + } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { + priceID = tier.StripeYearlyPriceID + } else { + return errNotAPaidTier + } + logvr(v, r). + With(tier). + Fields(log.Context{ + "stripe_price_id": priceID, + "stripe_subscription_interval": req.Interval, + }). + Tag(tagStripe). + Info("Creating Stripe checkout flow") + var stripeCustomerID *string + if u.Billing.StripeCustomerID != "" { + stripeCustomerID = &u.Billing.StripeCustomerID + stripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID) + if err != nil { + return err + } else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 { + return errMultipleBillingSubscriptions + } + } + successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate + params := &stripe.CheckoutSessionParams{ + Customer: stripeCustomerID, // A user may have previously deleted their subscription + ClientReferenceID: &u.ID, + SuccessURL: &successURL, + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + AllowPromotionCodes: stripe.Bool(true), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{ + Enabled: stripe.Bool(true), + }, + } + sess, err := s.stripe.NewCheckoutSession(params) + if err != nil { + return err + } + response := &apiAccountBillingSubscriptionCreateResponse{ + RedirectURL: sess.URL, + } + return s.writeJSON(w, response) +} + +// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use +// the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first +// and only time we can map the local username with the Stripe customer ID. +func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error { + // We don't have v.User() in this endpoint, only a userManager! + matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidPath + } + sessionID := matches[1] + sess, err := s.stripe.GetSession(sessionID) // FIXME How do we rate limit this? + if err != nil { + return err + } else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" { + return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found") + } + sub, err := s.stripe.GetSubscription(sess.Subscription.ID) + if err != nil { + return err + } else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil { + return errHTTPBadRequestBillingRequestInvalid.Wrap("more than one line item in existing subscription") + } + priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval + tier, err := s.userManager.TierByStripePrice(priceID) + if err != nil { + return err + } + u, err := s.userManager.UserByID(sess.ClientReferenceID) + if err != nil { + return err + } + v.SetUser(u) + logvr(v, r). + With(tier). + Tag(tagStripe). + Fields(log.Context{ + "stripe_customer_id": sess.Customer.ID, + "stripe_price_id": priceID, + "stripe_subscription_id": sub.ID, + "stripe_subscription_status": string(sub.Status), + "stripe_subscription_interval": string(interval), + "stripe_subscription_paid_until": sub.CurrentPeriodEnd, + }). + Info("Stripe checkout flow succeeded, updating user tier and subscription") + customerParams := &stripe.CustomerParams{ + Params: stripe.Params{ + Metadata: map[string]string{ + "user_id": u.ID, + "user_name": u.Name, + }, + }, + } + if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil { + return err + } + if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { + return err + } + http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) + return nil +} + +// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates +// a user's tier accordingly. This endpoint only works if there is an existing subscription. +func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + if u.Billing.StripeSubscriptionID == "" { + return errNoBillingSubscription + } + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + tier, err := s.userManager.Tier(req.Tier) + if err != nil { + return err + } + var priceID string + if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { + priceID = tier.StripeMonthlyPriceID + } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { + priceID = tier.StripeYearlyPriceID + } else { + return errNotAPaidTier + } + logvr(v, r). + Tag(tagStripe). + Fields(log.Context{ + "new_tier_id": tier.ID, + "new_tier_code": tier.Code, + "new_tier_stripe_price_id": priceID, + "new_tier_stripe_subscription_interval": req.Interval, + // Other stripe_* fields filled by visitor context + }). + Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval) + sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID) + if err != nil { + return err + } else if sub.Items == nil || len(sub.Items.Data) != 1 { + return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item") + } + params := &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(false), + ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), + Items: []*stripe.SubscriptionItemsParams{ + { + ID: stripe.String(sub.Items.Data[0].ID), + Price: stripe.String(priceID), + }, + }, + } + _, err = s.stripe.UpdateSubscription(sub.ID, params) + if err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user, +// and cancelling the Stripe subscription entirely. Note that this does not actually change the tier. +// That is done by a webhook at the period end (in X days). +func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription") + u := v.User() + if u.Billing.StripeSubscriptionID != "" { + params := &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(true), + } + _, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params) + if err != nil { + return err + } + } + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the +// redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription. +func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { + logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session") + u := v.User() + if u.Billing.StripeCustomerID == "" { + return errHTTPBadRequestNotAPaidUser + } + params := &stripe.BillingPortalSessionParams{ + Customer: stripe.String(u.Billing.StripeCustomerID), + ReturnURL: stripe.String(s.config.BaseURL), + } + ps, err := s.stripe.NewPortalSession(params) + if err != nil { + return err + } + response := &apiAccountBillingPortalRedirectResponse{ + RedirectURL: ps.URL, + } + return s.writeJSON(w, response) +} + +// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync +// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the +// visitor (v) in this endpoint is the Stripe API, so we don't have u available. +func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error { + stripeSignature := r.Header.Get("Stripe-Signature") + if stripeSignature == "" { + return errHTTPBadRequestBillingRequestInvalid + } + body, err := util.Peek(r.Body, jsonBodyBytesLimit) + if err != nil { + return err + } else if body.LimitReached { + return errHTTPEntityTooLargeJSONBody + } + event, err := s.stripe.ConstructWebhookEvent(body.PeekedBytes, stripeSignature, s.config.StripeWebhookKey) + if err != nil { + return err + } else if event.Data == nil || event.Data.Raw == nil { + return errHTTPBadRequestBillingRequestInvalid + } + switch event.Type { + case "customer.subscription.updated": + return s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event) + case "customer.subscription.deleted": + return s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event) + default: + logvr(v, r). + Tag(tagStripe). + Field("stripe_webhook_type", event.Type). + Warn("Unhandled Stripe webhook event %s received", event.Type) + return nil + } +} + +func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error { + ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw))) + if err != nil { + return err + } else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil { + logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe") + return errHTTPBadRequestBillingRequestInvalid + } + subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval + logvr(v, r). + Tag(tagStripe). + Fields(log.Context{ + "stripe_webhook_type": event.Type, + "stripe_customer_id": ev.Customer, + "stripe_price_id": priceID, + "stripe_subscription_id": ev.ID, + "stripe_subscription_status": ev.Status, + "stripe_subscription_interval": interval, + "stripe_subscription_paid_until": ev.CurrentPeriodEnd, + "stripe_subscription_cancel_at": ev.CancelAt, + }). + Info("Updating subscription to status %s, with price %s", ev.Status, priceID) + userFn := func() (*user.User, error) { + return s.userManager.UserByStripeCustomer(ev.Customer) + } + // We retry the user retrieval function, because during the Stripe checkout, there a race between the browser + // checkout success redirect (see handleAccountBillingSubscriptionCreateSuccess), and this webhook. The checkout + // success call is the one that updates the user with the Stripe customer ID. + u, err := util.Retry[user.User](userFn, retryUserDelays...) + if err != nil { + return err + } + v.SetUser(u) + tier, err := s.userManager.TierByStripePrice(priceID) + if err != nil { + return err + } + if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil { + return err + } + s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) + return nil +} + +func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error { + ev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw))) + if err != nil { + return err + } else if ev.Customer == "" { + return errHTTPBadRequestBillingRequestInvalid + } + u, err := s.userManager.UserByStripeCustomer(ev.Customer) + if err != nil { + return err + } + v.SetUser(u) + logvr(v, r). + Tag(tagStripe). + Field("stripe_webhook_type", event.Type). + Info("Subscription deleted, downgrading to unpaid tier") + if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil { + return err + } + s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) + return nil +} + +func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error { + reservationsLimit := visitorDefaultReservationsLimit + if tier != nil { + reservationsLimit = tier.ReservationLimit + } + if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil { + return err + } + if tier == nil && u.Tier != nil { + logvr(v, r).Tag(tagStripe).Info("Resetting tier for user %s", u.Name) + if err := s.userManager.ResetTier(u.Name); err != nil { + return err + } + } else if tier != nil && u.TierID() != tier.ID { + logvr(v, r). + Tag(tagStripe). + Fields(log.Context{ + "new_tier_id": tier.ID, + "new_tier_code": tier.Code, + }). + Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name) + if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil { + return err + } + } + // Update billing fields + billing := &user.Billing{ + StripeCustomerID: customerID, + StripeSubscriptionID: subscriptionID, + StripeSubscriptionStatus: stripe.SubscriptionStatus(status), + StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval), + StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0), + StripeSubscriptionCancelAt: time.Unix(cancelAt, 0), + } + if err := s.userManager.ChangeBilling(u.Name, billing); err != nil { + return err + } + return nil +} + +// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices +// in memory, and ultimately for the web app to display the price table. +func (s *Server) fetchStripePrices() (map[string]int64, error) { + log.Debug("Caching prices from Stripe API") + priceMap := make(map[string]int64) + prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)}) + if err != nil { + log.Warn("Fetching Stripe prices failed: %s", err.Error()) + return nil, err + } + for _, p := range prices { + priceMap[p.ID] = p.UnitAmount + log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID]) + } + return priceMap, nil +} + +// stripeAPI is a small interface to facilitate mocking of the Stripe API +type stripeAPI interface { + NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) + NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) + ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) + GetCustomer(id string) (*stripe.Customer, error) + GetSession(id string) (*stripe.CheckoutSession, error) + GetSubscription(id string) (*stripe.Subscription, error) + UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) + UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) + CancelSubscription(id string) (*stripe.Subscription, error) + ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) +} + +// realStripeAPI is a thin shim around the Stripe functions to facilitate mocking +type realStripeAPI struct{} + +var _ stripeAPI = (*realStripeAPI)(nil) + +func newStripeAPI() stripeAPI { + return &realStripeAPI{} +} + +func (s *realStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { + return session.New(params) +} + +func (s *realStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) { + return portalsession.New(params) +} + +func (s *realStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) { + prices := make([]*stripe.Price, 0) + iter := price.List(params) + for iter.Next() { + prices = append(prices, iter.Price()) + } + if iter.Err() != nil { + return nil, iter.Err() + } + return prices, nil +} + +func (s *realStripeAPI) GetCustomer(id string) (*stripe.Customer, error) { + return customer.Get(id, nil) +} + +func (s *realStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) { + return session.Get(id, nil) +} + +func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) { + return subscription.Get(id, nil) +} + +func (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) { + return customer.Update(id, params) +} + +func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) { + return subscription.Update(id, params) +} + +func (s *realStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) { + return subscription.Cancel(id, nil) +} + +func (s *realStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) { + return webhook.ConstructEvent(payload, header, secret) +} diff --git a/server/server_payments_test.go b/server/server_payments_test.go new file mode 100644 index 00000000..29a1b13d --- /dev/null +++ b/server/server_payments_test.go @@ -0,0 +1,856 @@ +package server + +import ( + "encoding/json" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v74" + "golang.org/x/time/rate" + "io" + "net/netip" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestPayments_Tiers(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + c.VisitorRequestLimitReplenish = 12 * time.Hour + c.CacheDuration = 13 * time.Hour + c.AttachmentFileSizeLimit = 111 + c.VisitorAttachmentTotalSizeLimit = 222 + c.AttachmentExpiryDuration = 123 * time.Second + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("ListPrices", mock.Anything). + Return([]*stripe.Price{ + {ID: "price_123", UnitAmount: 500}, + {ID: "price_124", UnitAmount: 5000}, + {ID: "price_456", UnitAmount: 1000}, + {ID: "price_457", UnitAmount: 10000}, + {ID: "price_999", UnitAmount: 9999}, + }, nil) + + // Create tiers + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "admin", + Name: "Admin", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + Name: "Pro", + MessageLimit: 1000, + MessageExpiryDuration: time.Hour, + EmailLimit: 123, + ReservationLimit: 777, + AttachmentFileSizeLimit: 999, + AttachmentTotalSizeLimit: 888, + AttachmentExpiryDuration: time.Minute, + StripeMonthlyPriceID: "price_123", + StripeYearlyPriceID: "price_124", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_444", + Code: "business", + Name: "Business", + MessageLimit: 2000, + MessageExpiryDuration: 10 * time.Hour, + EmailLimit: 123123, + ReservationLimit: 777333, + AttachmentFileSizeLimit: 999111, + AttachmentTotalSizeLimit: 888111, + AttachmentExpiryDuration: time.Hour, + StripeMonthlyPriceID: "price_456", + StripeYearlyPriceID: "price_457", + })) + response := request(t, s, "GET", "/v1/tiers", "", nil) + require.Equal(t, 200, response.Code) + var tiers []apiAccountBillingTier + require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers)) + require.Equal(t, 3, len(tiers)) + + // Free tier + tier := tiers[0] + require.Equal(t, "", tier.Code) + require.Equal(t, "", tier.Name) + require.Equal(t, "ip", tier.Limits.Basis) + require.Equal(t, int64(0), tier.Limits.Reservations) + require.Equal(t, int64(2), tier.Limits.Messages) // :-( + require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(24), tier.Limits.Emails) + require.Equal(t, int64(111), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration) + + // Admin tier is not included, because it is not paid! + + tier = tiers[1] + require.Equal(t, "pro", tier.Code) + require.Equal(t, "Pro", tier.Name) + require.Equal(t, "tier", tier.Limits.Basis) + require.Equal(t, int64(500), tier.Prices.Month) + require.Equal(t, int64(5000), tier.Prices.Year) + require.Equal(t, int64(777), tier.Limits.Reservations) + require.Equal(t, int64(1000), tier.Limits.Messages) + require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(123), tier.Limits.Emails) + require.Equal(t, int64(999), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration) + + tier = tiers[2] + require.Equal(t, "business", tier.Code) + require.Equal(t, "Business", tier.Name) + require.Equal(t, int64(1000), tier.Prices.Month) + require.Equal(t, int64(10000), tier.Prices.Year) + require.Equal(t, "tier", tier.Limits.Basis) + require.Equal(t, int64(777333), tier.Limits.Reservations) + require.Equal(t, int64(2000), tier.Limits.Messages) + require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(123123), tier.Limits.Emails) + require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration) +} + +func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("NewCheckoutSession", mock.Anything). + Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) + + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + // Create subscription + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) +} + +func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("GetCustomer", "acct_123"). + Return(&stripe.Customer{Subscriptions: &stripe.SubscriptionList{}}, nil) + stripeMock. + On("NewCheckoutSession", mock.Anything). + Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) + + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + u, err := s.userManager.User("phil") + require.Nil(t, err) + + billing := &user.Billing{ + StripeCustomerID: "acct_123", + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + + // Create subscription + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) +} + +func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.EnableSignup = true + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("CancelSubscription", "sub_123"). + Return(&stripe.Subscription{}, nil) + + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + u, err := s.userManager.User("phil") + require.Nil(t, err) + + billing := &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + + // Delete account + rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) +} + +func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) { + // This test is too overloaded, but it's also a great end-to-end a test. + // + // It tests: + // - A successful checkout flow (not a paying customer -> paying customer) + // - Tier-changes reset the rate limits for the user + // - The request limits for tier-less user and a tier-user + // - The message limits for a tier-user + + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + c.VisitorRequestLimitBurst = 5 + c.VisitorRequestLimitReplenish = time.Hour + c.CacheBatchSize = 500 + c.CacheBatchTimeout = time.Second + s := newTestServer(t, c) + s.stripe = stripeMock + + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "starter", + StripeMonthlyPriceID: "price_1234", + ReservationLimit: 1, + MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in + MessageExpiryDuration: time.Hour, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // No tier + u, err := s.userManager.User("phil") + require.Nil(t, err) + + // Define how the mock should react + stripeMock. + On("GetSession", "SOMETOKEN"). + Return(&stripe.CheckoutSession{ + ClientReferenceID: u.ID, // ntfy user ID + Customer: &stripe.Customer{ + ID: "acct_5555", + }, + Subscription: &stripe.Subscription{ + ID: "sub_1234", + }, + }, nil) + stripeMock. + On("GetSubscription", "sub_1234"). + Return(&stripe.Subscription{ + ID: "sub_1234", + Status: stripe.SubscriptionStatusActive, + CurrentPeriodEnd: 123456789, + CancelAt: 0, + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + { + Price: &stripe.Price{ + ID: "price_1234", + Recurring: &stripe.PriceRecurring{ + Interval: stripe.PriceRecurringIntervalMonth, + }, + }, + }, + }, + }, + }, nil) + stripeMock. + On("UpdateCustomer", "acct_5555", &stripe.CustomerParams{ + Params: stripe.Params{ + Metadata: map[string]string{ + "user_id": u.ID, + "user_name": u.Name, + }, + }, + }). + Return(&stripe.Customer{}, nil) + + // Send messages until rate limit of free tier is hit + for i := 0; i < 5; i++ { + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) + + // Verify some "before-stats" + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, u.Tier) + require.Equal(t, "", u.Billing.StripeCustomerID) + require.Equal(t, "", u.Billing.StripeSubscriptionID) + require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) + require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users! + require.Equal(t, int64(0), u.Stats.Emails) + + // Simulate Stripe success return URL call (no user context) + rr = request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, "starter", u.Tier.Code) // Not "pro" + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) + require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) + require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval) + require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + + // Now for the fun part: Verify that new rate limits are immediately applied + // This only tests the request limiter, which kicks in before the message limiter. + for i := 0; i < 11; i++ { + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code, "failed on iteration %d", i) + } + rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) + + // Now let's test the message limiter by faking a ridiculously generous rate limiter + v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) + v.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000) + + var wg sync.WaitGroup + for i := 0; i < 209; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code, "Failed on %d", i) + }(i) + } + wg.Wait() + rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) + + // And now let's cross-check that the stats are correct too + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(220), account.Limits.Messages) + require.Equal(t, int64(220), account.Stats.Messages) + require.Equal(t, int64(0), account.Stats.MessagesRemaining) +} + +func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) { + t.Parallel() + + // This tests incoming webhooks from Stripe to update a subscription: + // - All Stripe columns are updated in the user table + // - When downgrading, excess reservations are deleted, including messages and attachments in + // the corresponding topics + + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). + Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil) + + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "starter", + StripeMonthlyPriceID: "price_1234", // ! + ReservationLimit: 1, // ! + MessageLimit: 100, + MessageExpiryDuration: time.Hour, + AttachmentExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 1000000, + AttachmentTotalSizeLimit: 1000000, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_2", + Code: "pro", + StripeMonthlyPriceID: "price_1111", // ! + ReservationLimit: 3, // ! + MessageLimit: 200, + MessageExpiryDuration: time.Hour, + AttachmentExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 1000000, + AttachmentTotalSizeLimit: 1000000, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) + require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll)) + + // Add billing details + u, err := s.userManager.User("phil") + require.Nil(t, err) + + billing := &user.Billing{ + StripeCustomerID: "acct_5555", + StripeSubscriptionID: "sub_1234", + StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, + StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, + StripeSubscriptionPaidUntil: time.Unix(123, 0), + StripeSubscriptionCancelAt: time.Unix(456, 0), + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + + // Add some messages to "atopic" and "ztopic", everything in "ztopic" will be deleted + rr := request(t, s, "PUT", "/atopic", "some aaa message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PUT", "/atopic", strings.Repeat("a", 5000), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + a2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) + + rr = request(t, s, "PUT", "/ztopic", "some zzz message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PUT", "/ztopic", strings.Repeat("z", 5000), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + z2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) + + // Call the webhook: This does all the magic + rr = request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ + "Stripe-Signature": "stripe signature", + }) + require.Equal(t, 200, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, "starter", u.Tier.Code) // Not "pro" + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) + require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due" + require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month" + require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated + require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated + + // Verify that reservations were deleted + r, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted + require.Equal(t, "atopic", r[0].Topic) + + // Verify that messages and attachments were deleted + time.Sleep(time.Second) + s.execManager() + + ms, err := s.messageCache.Messages("atopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) + + ms, err = s.messageCache.Messages("ztopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(ms)) + require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) +} + +func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { + // This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is + // updated (all Stripe fields are deleted, and the tier is removed). + // + // It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call. + + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). + Return(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil) + + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "pro", + StripeMonthlyPriceID: "price_1234", + ReservationLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) + + // Add billing details + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{ + StripeCustomerID: "acct_5555", + StripeSubscriptionID: "sub_1234", + StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, + StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, + StripeSubscriptionPaidUntil: time.Unix(123, 0), + StripeSubscriptionCancelAt: time.Unix(0, 0), + })) + + // Call the webhook: This does all the magic + rr := request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ + "Stripe-Signature": "stripe signature", + }) + require.Equal(t, 200, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, u.Tier) + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "", u.Billing.StripeSubscriptionID) + require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + + // Verify that reservations were deleted + r, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 0, len(r)) +} + +func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("GetSubscription", "sub_123"). + Return(&stripe.Subscription{ + ID: "sub_123", + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + { + ID: "someid_123", + Price: &stripe.Price{ID: "price_123"}, + }, + }, + }, + }, nil) + stripeMock. + On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(false), + ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), + Items: []*stripe.SubscriptionItemsParams{ + { + ID: stripe.String("someid_123"), + Price: stripe.String("price_457"), + }, + }, + }). + Return(&stripe.Subscription{}, nil) + + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + StripeYearlyPriceID: "price_124", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_456", + Code: "business", + StripeMonthlyPriceID: "price_456", + StripeYearlyPriceID: "price_457", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) + + // Call endpoint to change subscription + rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) +} + +func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("UpdateSubscription", "sub_123", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool { + return *s.CancelAtPeriodEnd // Is true + })). + Return(&stripe.Subscription{}, nil) + + // Create user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) + + // Delete subscription + rr := request(t, s, "DELETE", "/v1/account/billing/subscription", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) +} + +func TestPayments_CreatePortalSession(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) + + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock + + // Define how the mock should react + stripeMock. + On("NewPortalSession", &stripe.BillingPortalSessionParams{ + Customer: stripe.String("acct_123"), + ReturnURL: stripe.String(s.config.BaseURL), + }). + Return(&stripe.BillingPortalSession{ + URL: "https://billing.stripe.com/blablabla", + }, nil) + + // Create user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) + + // Create portal session + rr := request(t, s, "POST", "/v1/account/billing/portal", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + ps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body)) + require.Equal(t, "https://billing.stripe.com/blablabla", ps.RedirectURL) +} + +type testStripeAPI struct { + mock.Mock +} + +var _ stripeAPI = (*testStripeAPI)(nil) + +func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { + args := s.Called(params) + return args.Get(0).(*stripe.CheckoutSession), args.Error(1) +} + +func (s *testStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) { + args := s.Called(params) + return args.Get(0).(*stripe.BillingPortalSession), args.Error(1) +} + +func (s *testStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) { + args := s.Called(params) + return args.Get(0).([]*stripe.Price), args.Error(1) +} + +func (s *testStripeAPI) GetCustomer(id string) (*stripe.Customer, error) { + args := s.Called(id) + return args.Get(0).(*stripe.Customer), args.Error(1) +} + +func (s *testStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) { + args := s.Called(id) + return args.Get(0).(*stripe.CheckoutSession), args.Error(1) +} + +func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) { + args := s.Called(id) + return args.Get(0).(*stripe.Subscription), args.Error(1) +} + +func (s *testStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) { + args := s.Called(id, params) + return args.Get(0).(*stripe.Customer), args.Error(1) +} + +func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) { + args := s.Called(id, params) + return args.Get(0).(*stripe.Subscription), args.Error(1) +} + +func (s *testStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) { + args := s.Called(id) + return args.Get(0).(*stripe.Subscription), args.Error(1) +} + +func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) { + args := s.Called(payload, header, secret) + return args.Get(0).(stripe.Event), args.Error(1) +} + +func jsonToStripeEvent(t *testing.T, v string) stripe.Event { + var e stripe.Event + if err := json.Unmarshal([]byte(v), &e); err != nil { + t.Fatal(err) + } + return e +} + +const subscriptionUpdatedEventJSON = ` +{ + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_1234", + "customer": "acct_5555", + "status": "active", + "current_period_end": 1674268231, + "cancel_at": 1674299999, + "items": { + "data": [ + { + "price": { + "id": "price_1234", + "recurring": { + "interval": "year" + } + } + } + ] + } + } + } +}` + +const subscriptionDeletedEventJSON = ` +{ + "type": "customer.subscription.deleted", + "data": { + "object": { + "id": "sub_1234", + "customer": "acct_5555", + "status": "active", + "current_period_end": 1674268231, + "cancel_at": 1674299999, + "items": { + "data": [ + { + "price": { + "id": "price_1234", + "recurring": { + "interval": "month" + } + } + } + ] + } + } + } +}` diff --git a/server/server_test.go b/server/server_test.go index 0f84b90a..85fdc211 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -6,20 +6,33 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" + "git.zio.sh/astra/ntfy/v2/user" + "golang.org/x/crypto/bcrypt" + "io" "math/rand" "net/http" "net/http/httptest" + "net/netip" "os" "path/filepath" + "runtime/debug" "strings" "sync" + "sync/atomic" "testing" "time" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/SherClockHolmes/webpush-go" + "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + log.SetLevel(log.ErrorLevel) + os.Exit(m.Run()) +} + func TestServer_PublishAndPoll(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -54,7 +67,51 @@ func TestServer_PublishAndPoll(t *testing.T) { require.Equal(t, "my second message", lines[1]) // \n -> " " } +func TestServer_PublishWithFirebase(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + + response := request(t, s, "PUT", "/mytopic", "my first message", nil) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 1, len(sender.Messages())) + require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) + require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) + require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"]) +} + +func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { + // This tests issue #641, which used to panic before the fix + + firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json") + contents := `{ + "type": "service_account", + "project_id": "ntfy-test", + "private_key_id": "fsfhskjdfhskdhfskdjfhsdf", + "private_key": "lalala", + "client_email": "firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com", + "client_id": "123123213", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com" +} +` + require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600)) + c := newTestConfig(t) + c.FirebaseKeyFile = firebaseKeyFile + s := newTestServer(t, c) + + response := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message) +} + func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { + t.Parallel() c := newTestConfig(t) c.KeepaliveInterval = time.Second s := newTestServer(t, c) @@ -93,6 +150,7 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { } func TestServer_PublishAndSubscribe(t *testing.T) { + t.Parallel() s := newTestServer(t, newTestConfig(t)) subscribeRR := httptest.NewRecorder() @@ -100,6 +158,7 @@ func TestServer_PublishAndSubscribe(t *testing.T) { publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil) require.Equal(t, 200, publishFirstRR.Code) + time.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{ "Title": " This is a title ", @@ -119,6 +178,8 @@ func TestServer_PublishAndSubscribe(t *testing.T) { require.Equal(t, "", messages[1].Title) require.Equal(t, 0, messages[1].Priority) require.Nil(t, messages[1].Tags) + require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires) + require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires) require.Equal(t, messageEvent, messages[2].Event) require.Equal(t, "mytopic", messages[2].Topic) @@ -128,6 +189,19 @@ func TestServer_PublishAndSubscribe(t *testing.T) { require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) } +func TestServer_Publish_Disallowed_Topic(t *testing.T) { + c := newTestConfig(t) + c.DisallowedTopics = []string{"about", "time", "this", "got", "added"} + s := newTestServer(t, c) + + rr := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PUT", "/about", "another message", nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code) +} + func TestServer_StaticSites(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -146,20 +220,72 @@ func TestServer_StaticSites(t *testing.T) { rr = request(t, s, "GET", "/mytopic", "", nil) require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), ``) - - rr = request(t, s, "GET", "/static/css/home.css", "", nil) - require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), `html, body {`) + require.Contains(t, rr.Body.String(), ``) rr = request(t, s, "GET", "/docs", "", nil) require.Equal(t, 301, rr.Code) // Docs test removed, it was failing annoyingly. +} - rr = request(t, s, "GET", "/example.html", "", nil) +func TestServer_WebEnabled(t *testing.T) { + conf := newTestConfig(t) + conf.WebRoot = "" // Disable web app + s := newTestServer(t, conf) + + rr := request(t, s, "GET", "/", "", nil) + require.Equal(t, 404, rr.Code) + + rr = request(t, s, "GET", "/config.js", "", nil) + require.Equal(t, 404, rr.Code) + + rr = request(t, s, "GET", "/sw.js", "", nil) + require.Equal(t, 404, rr.Code) + + rr = request(t, s, "GET", "/app.html", "", nil) + require.Equal(t, 404, rr.Code) + + rr = request(t, s, "GET", "/static/css/home.css", "", nil) + require.Equal(t, 404, rr.Code) + + conf2 := newTestConfig(t) + conf2.WebRoot = "/" + s2 := newTestServer(t, conf2) + + rr = request(t, s2, "GET", "/", "", nil) require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), "") + + rr = request(t, s2, "GET", "/config.js", "", nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s2, "GET", "/sw.js", "", nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s2, "GET", "/app.html", "", nil) + require.Equal(t, 200, rr.Code) +} + +func TestServer_WebPushEnabled(t *testing.T) { + conf := newTestConfig(t) + conf.WebRoot = "" // Disable web app + s := newTestServer(t, conf) + + rr := request(t, s, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 404, rr.Code) + + conf2 := newTestConfig(t) + s2 := newTestServer(t, conf2) + + rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 404, rr.Code) + + conf3 := newTestConfigWithWebPush(t) + s3 := newTestServer(t, conf3) + + rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 200, rr.Code) + require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type")) + } func TestServer_PublishLargeMessage(t *testing.T) { @@ -203,6 +329,27 @@ func TestServer_PublishPriority(t *testing.T) { require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) } +func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Priority": "u=4", + "X-Priority": "5", + }) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "POST", "/mytopic?priority=4", "test", map[string]string{ + "Priority": "u=9", + }) + require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "POST", "/mytopic", "test", map[string]string{ + "p": "2", + "priority": "u=9, i", + }) + require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) +} + func TestServer_PublishGETOnlyOneTopic(t *testing.T) { // This tests a bug that allowed publishing topics with a comma in the name (no ticket) @@ -220,6 +367,7 @@ func TestServer_PublishNoCache(t *testing.T) { msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) require.Equal(t, "this message is not cached", msg.Message) + require.Equal(t, int64(0), msg.Expires) response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages := toMessages(t, response.Body.String()) @@ -227,13 +375,11 @@ func TestServer_PublishNoCache(t *testing.T) { } func TestServer_PublishAt(t *testing.T) { - c := newTestConfig(t) - c.MinDelay = time.Second - c.AtSenderInterval = 100 * time.Millisecond - s := newTestServer(t, c) + t.Parallel() + s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "1s", + "In": "1h", }) require.Equal(t, 200, response.Code) @@ -241,13 +387,72 @@ func TestServer_PublishAt(t *testing.T) { messages := toMessages(t, response.Body.String()) require.Equal(t, 0, len(messages)) - time.Sleep(time.Second) - require.Nil(t, s.sendDelayedMessages()) + // Update message time to the past + fakeTime := time.Now().Add(-10 * time.Second).Unix() + _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) + require.Nil(t, err) + // Trigger delayed message sending + require.Nil(t, s.sendDelayedMessages()) response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages = toMessages(t, response.Body.String()) require.Equal(t, 1, len(messages)) require.Equal(t, "a message", messages[0].Message) + require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender! + + messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) + require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though! +} + +func TestServer_PublishAt_FromUser(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfigWithAuthFile(t)) + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "In": "1h", + }) + require.Equal(t, 200, response.Code) + + // Message doesn't show up immediately + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 0, len(messages)) + + // Update message time to the past + fakeTime := time.Now().Add(-10 * time.Second).Unix() + _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) + require.Nil(t, err) + + // Trigger delayed message sending + require.Nil(t, s.sendDelayedMessages()) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, fakeTime, messages[0].Time) + require.Equal(t, "a message", messages[0].Message) + + messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) + require.True(t, strings.HasPrefix(messages[0].User, "u_")) +} + +func TestServer_PublishAt_Expires(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "2 days", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix()) + require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix()) } func TestServer_PublishAtWithCacheError(t *testing.T) { @@ -301,12 +506,14 @@ func TestServer_PublishAtAndPrune(t *testing.T) { "In": "1h", }) require.Equal(t, 200, response.Code) - s.updateStatsAndPrune() // Fire pruning + s.execManager() // Fire pruning response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) messages := toMessages(t, response.Body.String()) require.Equal(t, 1, len(messages)) // Not affected by pruning require.Equal(t, "a message", messages[0].Message) + + time.Sleep(time.Second) // FIXME CI failing not sure why } func TestServer_PublishAndMultiPoll(t *testing.T) { @@ -363,6 +570,7 @@ func TestServer_PublishWithNopCache(t *testing.T) { } func TestServer_PublishAndPollSince(t *testing.T) { + t.Parallel() s := newTestServer(t, newTestConfig(t)) request(t, s, "PUT", "/mytopic", "test 1", nil) @@ -390,6 +598,53 @@ func TestServer_PublishAndPollSince(t *testing.T) { require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) } +func newMessageWithTimestamp(topic, message string, timestamp int64) *message { + m := newDefaultMessage(topic, message) + m.Time = timestamp + return m +} + +func TestServer_PollSinceID_MultipleTopics(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277))) + markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283) + require.Nil(t, s.messageCache.AddMessage(markerMessage)) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) + + response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 4, len(messages)) + require.Equal(t, "test 3", messages[0].Message) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "test 4", messages[1].Message) + require.Equal(t, "mytopic2", messages[1].Topic) + require.Equal(t, "test 5", messages[2].Message) + require.Equal(t, "mytopic1", messages[2].Topic) + require.Equal(t, "test 6", messages[3].Message) + require.Equal(t, "mytopic2", messages[3].Topic) +} + +func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) + + response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 4, len(messages)) + require.Equal(t, "test 3", messages[0].Message) + require.Equal(t, "test 4", messages[1].Message) + require.Equal(t, "test 5", messages[2].Message) + require.Equal(t, "test 6", messages[3].Message) +} + func TestServer_PublishViaGET(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -419,29 +674,9 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! } -func TestServer_PublishFirebase(t *testing.T) { - // This is unfortunately not much of a test, since it merely fires the messages towards Firebase, - // but cannot re-read them. There is no way from Go to read the messages back, or even get an error back. - // I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ... - - c := newTestConfig(t) - c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test! - s := newTestServer(t, c) - - // Normal message - response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - - // Keepalive message - require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic))) - - time.Sleep(500 * time.Millisecond) // Time for sends -} - func TestServer_PublishInvalidTopic(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - s.mailer = &testMailer{} + s.smtpSender = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) } @@ -516,6 +751,7 @@ func TestServer_PollWithQueryFilters(t *testing.T) { } func TestServer_SubscribeWithQueryFilters(t *testing.T) { + t.Parallel() c := newTestConfig(t) c.KeepaliveInterval = 800 * time.Millisecond s := newTestServer(t, c) @@ -542,56 +778,48 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) { } func TestServer_Auth_Success_Admin(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") + c := newTestConfigWithAuthFile(t) s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": basicAuth("phil:phil"), + "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) require.Equal(t, `{"success":true}`+"\n", response.Body.String()) } func TestServer_Auth_Success_User(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = false - c.AuthDefaultWrite = false + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": basicAuth("ben:ben"), + "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 200, response.Code) } func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = false - c.AuthDefaultWrite = false + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, manager.AllowAccess("ben", "anothertopic", true, true)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite)) response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{ - "Authorization": basicAuth("ben:ben"), + "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 200, response.Code) response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{ - "Authorization": basicAuth("ben:ben"), + "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 403, response.Code) } @@ -599,47 +827,39 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { func TestServer_Auth_Fail_InvalidPass(t *testing.T) { c := newTestConfig(t) c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = false - c.AuthDefaultWrite = false + c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": basicAuth("phil:INVALID"), + "Authorization": util.BasicAuth("phil", "INVALID"), }) require.Equal(t, 401, response.Code) } func TestServer_Auth_Fail_Unauthorized(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = false - c.AuthDefaultWrite = false + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser)) - require.Nil(t, manager.AllowAccess("ben", "sometopic", true, true)) // Not mytopic! + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic! response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": basicAuth("ben:ben"), + "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 403, response.Code) } func TestServer_Auth_Fail_CannotPublish(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = true // Open by default - c.AuthDefaultWrite = true // Open by default + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionReadWrite // Open by default s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin)) - require.Nil(t, manager.AllowAccess(auth.Everyone, "private", false, false)) - require.Nil(t, manager.AllowAccess(auth.Everyone, "announcements", true, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) response := request(t, s, "PUT", "/mytopic", "test", nil) require.Equal(t, 200, response.Code) @@ -651,7 +871,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { require.Equal(t, 403, response.Code) // Cannot write as anonymous response = request(t, s, "PUT", "/announcements", "test", map[string]string{ - "Authorization": basicAuth("phil:phil"), + "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) @@ -662,58 +882,266 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { require.Equal(t, 403, response.Code) // Anonymous read not allowed } -func TestServer_Auth_ViaQuery(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefaultRead = false - c.AuthDefaultWrite = false +func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorAuthFailureLimitBurst = 10 s := newTestServer(t, c) - manager := s.auth.(auth.Manager) - require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin)) + for i := 0; i < 10; i++ { + response := request(t, s, "PUT", "/announcements", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 401, response.Code) + } - u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass")))) + response := request(t, s, "PUT", "/announcements", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, response.Code) + require.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Auth_ViaQuery(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + + require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin)) + + u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass")))) response := request(t, s, "GET", u, "", nil) require.Equal(t, 200, response.Code) - u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG")))) + u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "WRONNNGGGG")))) response = request(t, s, "GET", u, "", nil) require.Equal(t, 401, response.Code) } -/* -func TestServer_Curl_Publish_Poll(t *testing.T) { - s, port := test.StartServer(t) - defer test.StopServer(t, s, port) +func TestServer_Auth_NonBasicHeader(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) - cmd := exec.Command("sh", "-c", fmt.Sprintf(`curl -sd "This is a test" localhost:%d/mytopic`, port)) - require.Nil(t, cmd.Run()) - b, err := cmd.CombinedOutput() - require.Nil(t, err) - msg := toMessage(t, string(b)) - require.Equal(t, "This is a test", msg.Message) + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "WebPush not-supported", + }) + require.Equal(t, 200, response.Code) - cmd = exec.Command("sh", "-c", fmt.Sprintf(`curl "localhost:%d/mytopic?poll=1"`, port)) - require.Nil(t, cmd.Run()) - b, err = cmd.CombinedOutput() - require.Nil(t, err) - msg = toMessage(t, string(b)) - require.Equal(t, "This is a test", msg.Message) + response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "Bearer supported", + }) + require.Equal(t, 401, response.Code) + + response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "basic supported", + }) + require.Equal(t, 401, response.Code) +} + +func TestServer_StatsResetter(t *testing.T) { + t.Parallel() + // This tests the stats resetter for + // - an anonymous user + // - a user without a tier (treated like the same as the anonymous user) + // - a user with a tier + + c := newTestConfigWithAuthFile(t) + c.VisitorStatsResetTime = time.Now().Add(2 * time.Second) + s := newTestServer(t, c) + go s.runStatsResetter() + + // Create user with tier (tieruser) and user without tier (phil) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 5, + MessageExpiryDuration: -5 * time.Second, // Second, what a hack! + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("tieruser", "test")) + + // Send an anonymous message + response := request(t, s, "PUT", "/mytopic", "test", nil) + require.Equal(t, 200, response.Code) + + // Send messages from user without tier (phil) + for i := 0; i < 5; i++ { + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + } + + // Send messages from user with tier + for i := 0; i < 2; i++ { + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("tieruser", "tieruser"), + }) + require.Equal(t, 200, response.Code) + } + + // User stats show 6 messages (for user without tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(6), account.Stats.Messages) + + // User stats show 6 messages (for anonymous visitor) + response = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(6), account.Stats.Messages) + + // User stats show 2 messages (for user with tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("tieruser", "tieruser"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(2), account.Stats.Messages) + + // Wait for stats resetter to run + waitFor(t, func() bool { + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + return account.Stats.Messages == 0 + }) + + // User stats show 0 messages now! + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) + + // Since this is a user without a tier, the anonymous user should have the same stats + response = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) + + // User stats show 0 messages (for user with tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("tieruser", "tieruser"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) +} + +func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { + // This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket) + // is reset by the stats resetter + + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) + s.smtpSender = &testMailer{} + + // Publish some messages, and check stats + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", "test", nil) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Email": "test@email.com", + }) + require.Equal(t, 200, response.Code) + + rr := request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(4), account.Stats.Messages) + require.Equal(t, int64(1), account.Stats.Emails) + v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) + require.Equal(t, int64(4), v.Stats().Messages) + require.Equal(t, int64(4), v.messagesLimiter.Value()) + require.Equal(t, int64(1), v.Stats().Emails) + require.Equal(t, int64(1), v.emailsLimiter.Value()) + + // Reset stats and check again + s.resetStats() + rr = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) + require.Equal(t, int64(0), account.Stats.Emails) + v = s.visitor(netip.MustParseAddr("9.9.9.9"), nil) + require.Equal(t, int64(0), v.Stats().Messages) + require.Equal(t, int64(0), v.messagesLimiter.Value()) + require.Equal(t, int64(0), v.Stats().Emails) + require.Equal(t, int64(0), v.emailsLimiter.Value()) +} + +func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { + t.Parallel() + + // This tests that the daily message quota is prefilled originally from the database, + // if the visitor is unknown + + c := newTestConfigWithAuthFile(t) + c.AuthStatsQueueWriterInterval = 100 * time.Millisecond + s := newTestServer(t, c) + + // Create user, and update it with some message and email stats + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + u, err := s.userManager.User("phil") + require.Nil(t, err) + s.userManager.EnqueueUserStats(u.ID, &user.Stats{ + Messages: 123456, + Emails: 999, + }) + time.Sleep(400 * time.Millisecond) + + // Get account and verify stats are read from the DB, and that the visitor also has these stats + rr := request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(123456), account.Stats.Messages) + require.Equal(t, int64(999), account.Stats.Emails) + v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) + require.Equal(t, int64(123456), v.Stats().Messages) + require.Equal(t, int64(123456), v.messagesLimiter.Value()) + require.Equal(t, int64(999), v.Stats().Emails) + require.Equal(t, int64(999), v.emailsLimiter.Value()) } -*/ type testMailer struct { count int mu sync.Mutex } -func (t *testMailer) Send(from, to string, m *message) error { +func (t *testMailer) Send(v *visitor, m *message, to string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ return nil } +func (t *testMailer) Counts() (total int64, success int64, failure int64) { + return 0, 0, 0 +} + func (t *testMailer) Count() int { t.mu.Lock() defer t.mu.Unlock() @@ -732,18 +1160,32 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) { func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) { c := newTestConfig(t) - c.VisitorRequestExemptIPAddrs = []string{"9.9.9.9"} // see request() + c.VisitorRequestLimitBurst = 3 + c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() s := newTestServer(t, c) - for i := 0; i < 65; i++ { // > 60 + for i := 0; i < 5; i++ { // > 3 response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) require.Equal(t, 200, response.Code) } } +func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 10 + c.VisitorMessageDailyLimit = 4 + c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() + s := newTestServer(t, c) + for i := 0; i < 8; i++ { // 4 + response := request(t, s, "PUT", "/mytopic", "message", nil) + require.Equal(t, 200, response.Code) + } +} + func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { + t.Parallel() c := newTestConfig(t) c.VisitorRequestLimitBurst = 60 - c.VisitorRequestLimitReplenish = 500 * time.Millisecond + c.VisitorRequestLimitReplenish = time.Second s := newTestServer(t, c) for i := 0; i < 60; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) @@ -752,14 +1194,14 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { response := request(t, s, "PUT", "/mytopic", "message", nil) require.Equal(t, 429, response.Code) - time.Sleep(510 * time.Millisecond) + time.Sleep(1020 * time.Millisecond) response = request(t, s, "PUT", "/mytopic", "message", nil) require.Equal(t, 200, response.Code) } func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - s.mailer = &testMailer{} + s.smtpSender = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -773,10 +1215,11 @@ func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { } func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { + t.Parallel() c := newTestConfig(t) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) - s.mailer = &testMailer{} + s.smtpSender = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -802,12 +1245,25 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - s.mailer = &testMailer{} + s.smtpSender = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", "Delay": "20 min", }) - require.Equal(t, 400, response.Code) + require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_PublishDelayedCall_Fail(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "Call": "yes", + "Delay": "20 min", + }) + require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { @@ -818,6 +1274,63 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { require.Equal(t, 400, response.Code) } +func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + subFn := func(v *visitor, msg *message) error { + return nil + } + + // Publish and check last access + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Cache": "no", + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + // .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine + s.topics["mytopic"].mu.RLock() + defer s.topics["mytopic"].mu.RUnlock() + return s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2 && + s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2 + }) + + // Topic won't get pruned + s.execManager() + require.NotNil(t, s.topics["mytopic"]) + + // Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber) + subID := s.topics["mytopic"].Subscribe(subFn, "", func() {}) + s.topics["mytopic"].mu.Lock() + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + s.topics["mytopic"].mu.Unlock() + s.execManager() + require.NotNil(t, s.topics["mytopic"]) + + // It'll finally get pruned now that there are no subscribers and last access is 17 hours ago + s.topics["mytopic"].Unsubscribe(subID) + s.execManager() + require.Nil(t, s.topics["mytopic"]) +} + +func TestServer_TopicKeepaliveOnPoll(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Create topic by polling once + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + + // Mess with last access time + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + + // Poll again and check keepalive time + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2) + require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2) +} + func TestServer_UnifiedPushDiscovery(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "GET", "/mytopic?up=1", "", nil) @@ -831,7 +1344,15 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { require.Nil(t, err) s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil) + + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{ + "Rate-Topics": "up123456789012", + }) + require.Equal(t, 200, response.Code) + + // Publish message to topic + response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -840,7 +1361,8 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { require.Nil(t, err) require.Equal(t, b, b2) - response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil) + // Retrieve and check published message + response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil) require.Equal(t, 200, response.Code) m = toMessage(t, response.Body.String()) require.Equal(t, "base64", m.Encoding) @@ -855,7 +1377,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { require.Nil(t, err) s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil) + + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ + "Rate-Topics": "mytopic", + }) + require.Equal(t, 200, response.Code) + + // Publish message to topic + response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -868,7 +1398,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { func TestServer_PublishUnifiedPushText(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) + + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ + "Rate-Topics": "mytopic", + }) + require.Equal(t, 200, response.Code) + + // Publish UnifiedPush text message + response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -876,6 +1414,113 @@ func TestServer_PublishUnifiedPushText(t *testing.T) { require.Equal(t, "this is a unifiedpush text message", m.Message) } +func TestServer_MatrixGateway_Discovery_Success(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String()) +} + +func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) { + c := newTestConfig(t) + c.BaseURL = "" + s := newTestServer(t, c) + response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) + require.Equal(t, 500, response.Code) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 50003, err.Code) +} + +func TestServer_MatrixGateway_Push_Success(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ + "Rate-Topics": "mytopic", // Register first! + }) + require.Equal(t, 200, response.Code) + + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, notification, m.Message) +} + +func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { + c := newTestConfig(t) + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 507, response.Code) + require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) { + c := newTestConfig(t) + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + + // No success if no rate visitor set (this also creates the topic in memory) + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 507, response.Code) + require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) + require.Nil(t, s.topics["mytopic"].rateVisitor) + + // Fake: This topic has been around for 13 hours without a rate visitor + s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour) + + // Same request should now return HTTP 200 with a rejected pushkey + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String())) + + // Slightly unrelated: Test that topic is pruned after 16 hours + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + s.execManager() + require.Nil(t, s.topics["mytopic"]) +} + +func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "", response.Body.String()) // Empty! +} + +func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"message":"this is not really a Matrix message"}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 400, response.Code) + require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) + + notification = `this isn't even JSON'` + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 400, response.Code) + require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { + c := newTestConfig(t) + c.BaseURL = "" + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 500, response.Code) + require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PublishActions_AndPoll(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{ @@ -896,11 +1541,44 @@ func TestServer_PublishActions_AndPoll(t *testing.T) { require.Equal(t, "target_temp_f=65", m.Actions[1].Body) } +func TestServer_PublishMarkdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ + "Content-Type": "text/markdown", + }) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "**make this bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + +func TestServer_PublishMarkdown_QueryParam(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?md=1", "**make this bold**", nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "**make this bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + +func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ + "Content-Type": "not-markdown", + }) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.ContentType) +} + func TestServer_PublishAsJSON(t *testing.T) { s := newTestServer(t, newTestConfig(t)) body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + `"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` + - `"delay":"30min"}` + `"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) @@ -912,18 +1590,51 @@ func TestServer_PublishAsJSON(t *testing.T) { require.Equal(t, "http://google.com", m.Attachment.URL) require.Equal(t, "google.pdf", m.Attachment.Name) require.Equal(t, "http://ntfy.sh", m.Click) + require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) + require.Equal(t, "", m.ContentType) + require.Equal(t, 4, m.Priority) require.True(t, m.Time > time.Now().Unix()+29*60) require.True(t, m.Time < time.Now().Unix()+31*60) } +func TestServer_PublishAsJSON_Markdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "**This is bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + +func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { + // Publishing as JSON follows a different path. This ensures that rate + // limiting works for this endpoint as well + c := newTestConfig(t) + c.VisitorMessageDailyLimit = 3 + s := newTestServer(t, c) + + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) + require.Equal(t, 429, response.Code) + require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PublishAsJSON_WithEmail(t *testing.T) { + t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t)) - s.mailer = mailer + s.smtpSender = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) + time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine m := toMessage(t, response.Body.String()) require.Equal(t, "mytopic", m.Topic) @@ -973,8 +1684,44 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) { require.Equal(t, 400, response.Code) } +func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) { + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) + + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 5, + MessageExpiryDuration: -5 * time.Second, // Second, what a hack! + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish to reach message limit + for i := 0; i < 5; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.True(t, msg.Expires < time.Now().Unix()+5) + } + response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, response.Code) + + // Run pruning and see if they are gone + s.execManager() + response = request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Empty(t, response.Body) +} + func TestServer_PublishAttachment(t *testing.T) { - content := util.RandomString(5000) // > 4096 + content := "text file!" + util.RandomString(4990) // > 4096 s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", content, nil) msg := toMessage(t, response.Body.String()) @@ -983,17 +1730,24 @@ func TestServer_PublishAttachment(t *testing.T) { require.Equal(t, int64(5000), msg.Attachment.Size) require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + // GET path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") response = request(t, s, "GET", path, "", nil) require.Equal(t, 200, response.Code) require.Equal(t, "5000", response.Header().Get("Content-Length")) require.Equal(t, content, response.Body.String()) + // HEAD + response = request(t, s, "HEAD", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "5000", response.Header().Get("Content-Length")) + require.Equal(t, "", response.Body.String()) + // Slightly unrelated cross-test: make sure we add an owner for internal attachments - size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request() + size, err := s.messageCache.AttachmentBytesUsedBySender("9.9.9.9") // See request() require.Nil(t, err) require.Equal(t, int64(5000), size) } @@ -1012,7 +1766,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { require.Equal(t, int64(21), msg.Attachment.Size) require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") @@ -1022,7 +1776,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { require.Equal(t, content, response.Body.String()) // Slightly unrelated cross-test: make sure we add an owner for internal attachments - size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4") + size, err := s.messageCache.AttachmentBytesUsedBySender("1.2.3.4") require.Nil(t, err) require.Equal(t, int64(21), size) } @@ -1039,10 +1793,10 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { require.Equal(t, "", msg.Attachment.Type) require.Equal(t, int64(0), msg.Attachment.Size) require.Equal(t, int64(0), msg.Attachment.Expires) - require.Equal(t, "", msg.Attachment.Owner) + require.Equal(t, netip.Addr{}, msg.Sender) // Slightly unrelated cross-test: make sure we don't add an owner for external attachments - size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1") + size, err := s.messageCache.AttachmentBytesUsedBySender("127.0.0.1") require.Nil(t, err) require.Equal(t, int64(0), size) } @@ -1060,7 +1814,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { require.Equal(t, "", msg.Attachment.Type) require.Equal(t, int64(0), msg.Attachment.Size) require.Equal(t, int64(0), msg.Attachment.Expires) - require.Equal(t, "", msg.Attachment.Owner) + require.Equal(t, netip.Addr{}, msg.Sender) } func TestServer_PublishAttachmentBadURL(t *testing.T) { @@ -1114,7 +1868,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t * c.VisitorAttachmentTotalSizeLimit = 10000 s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil) + response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil) msg := toMessage(t, response.Body.String()) require.Equal(t, 200, response.Code) require.Equal(t, "You received a file: attachment.txt", msg.Message) @@ -1128,7 +1882,8 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t * require.Equal(t, 41301, err.Code) } -func TestServer_PublishAttachmentAndPrune(t *testing.T) { +func TestServer_PublishAttachmentAndExpire(t *testing.T) { + t.Parallel() content := util.RandomString(5000) // > 4096 c := newTestConfig(t) @@ -1148,13 +1903,154 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) { require.Equal(t, content, response.Body.String()) // Prune and makes sure it's gone - time.Sleep(time.Second) // Sigh ... - s.updateStatsAndPrune() - require.NoFileExists(t, file) + waitFor(t, func() bool { + s.execManager() // May run many times + return !util.FileExists(file) + }) response = request(t, s, "GET", path, "", nil) require.Equal(t, 404, response.Code) } +func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { + t.Parallel() + content := util.RandomString(5000) // > 4096 + + c := newTestConfigWithAuthFile(t) + c.AttachmentExpiryDuration = time.Millisecond // Hack + s := newTestServer(t, c) + + // Create tier with certain limits + sevenDays := time.Duration(604800) * time.Second + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 10, + MessageExpiryDuration: sevenDays, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: sevenDays, // 7 days + AttachmentBandwidthLimit: 100000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish and make sure we can retrieve it + response := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) + require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) + file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, file) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + + // Prune and makes sure it's still there + time.Sleep(time.Second) // Sigh ... + s.execManager() + require.FileExists(t, file) + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) +} + +func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfigWithAuthFile(t) + c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth! + s := newTestServer(t, c) + + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 10, + MessageExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: time.Hour, + AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish and make sure we can retrieve it + rr := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + msg := toMessage(t, rr.Body.String()) + + // Retrieve it (first time succeeds) + rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) // File downloads do not send auth headers!! + require.Equal(t, 200, rr.Code) + require.Equal(t, content, rr.Body.String()) + + // Retrieve it AGAIN (fails, due to bandwidth limit) + rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) + require.Equal(t, 429, rr.Code) +} + +func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { + smallFile := util.RandomString(20_000) + largeFile := util.RandomString(50_000) + + c := newTestConfigWithAuthFile(t) + c.AttachmentFileSizeLimit = 20_000 + c.VisitorAttachmentTotalSizeLimit = 40_000 + s := newTestServer(t, c) + + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 100, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: 30 * time.Second, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish small file as anonymous + response := request(t, s, "PUT", "/mytopic", smallFile, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + // Publish large file as anonymous + response = request(t, s, "PUT", "/mytopic", largeFile, nil) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) + + // Publish too large file as phil + response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) + + // Publish large file as phil (4x) + for i := 0; i < 4; i++ { + response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg = toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + } + response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { content := util.RandomString(5000) // > 4096 @@ -1167,7 +2063,7 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { msg := toMessage(t, response.Body.String()) require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - // Get it 4 times successfully + // Value it 4 times successfully path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") for i := 1; i <= 4; i++ { // 4 successful downloads response = request(t, s, "GET", path, "", nil) @@ -1203,7 +2099,26 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { require.Equal(t, 41301, err.Code) } -func TestServer_PublishAttachmentUserStats(t *testing.T) { +func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) { + // This tests the awkward util.Retry in handleFile: Due to the async persisting of messages, + // the message is not immediately available when attempting to download it. + + c := newTestConfig(t) + c.CacheBatchTimeout = 500 * time.Millisecond + c.CacheBatchSize = 10 + s := newTestServer(t, c) + content := "this is an ATTACHMENT" + rr := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) + m := toMessage(t, rr.Body.String()) + require.Equal(t, "myfile.txt", m.Attachment.Name) + + path := strings.TrimPrefix(m.Attachment.URL, "http://127.0.0.1:12345") + rr = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, rr.Code) // Not 404! + require.Equal(t, content, rr.Body.String()) +} + +func TestServer_PublishAttachmentAccountStats(t *testing.T) { content := util.RandomString(4999) // > 4096 c := newTestConfig(t) @@ -1217,43 +2132,614 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) { require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") // User stats - response = request(t, s, "GET", "/user/stats", "", nil) + response = request(t, s, "GET", "/v1/account", "", nil) require.Equal(t, 200, response.Code) - var stats visitorStats - require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats)) - require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit) - require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal) - require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed) - require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(5000), account.Limits.AttachmentFileSize) + require.Equal(t, int64(6000), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(4999), account.Stats.AttachmentTotalSize) + require.Equal(t, int64(1001), account.Stats.AttachmentTotalSizeRemaining) + require.Equal(t, int64(1), account.Stats.Messages) +} + +func TestServer_Visitor_XForwardedFor_None(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11" + r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "8.9.10.11", v.ip.String()) +} + +func TestServer_Visitor_XForwardedFor_Single(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11" + r.Header.Set("X-Forwarded-For", "1.1.1.1") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "1.1.1.1", v.ip.String()) +} + +func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11" + r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "234.5.2.1", v.ip.String()) +} + +func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { + t.Parallel() + count := 50000 + c := newTestConfig(t) + c.TotalTopicLimit = 50001 + c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + s := newTestServer(t, c) + + // Add lots of messages + log.Info("Adding %d messages", count) + start := time.Now() + messages := make([]*message, 0) + for i := 0; i < count; i++ { + topicID := fmt.Sprintf("topic%d", i) + _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array + require.Nil(t, err) + messages = append(messages, newDefaultMessage(topicID, "some message")) + } + require.Nil(t, s.messageCache.addMessages(messages)) + log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) + + // Update stats + statsChan := make(chan bool) + go func() { + log.Info("Updating stats") + start := time.Now() + s.execManager() + log.Info("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond)) + statsChan <- true + }() + time.Sleep(50 * time.Millisecond) // Make sure it starts first + + // Publish message (during stats update) + log.Info("Publishing message") + start = time.Now() + response := request(t, s, "PUT", "/mytopic", "some body", nil) + m := toMessage(t, response.Body.String()) + require.Equal(t, "some body", m.Message) + require.True(t, time.Since(start) < 100*time.Millisecond) + log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) + + // Wait for all goroutines + select { + case <-statsChan: + case <-time.After(10 * time.Second): + t.Fatal("Timed out waiting for Go routines") + } + log.Info("Done: Waiting for all locks") +} + +func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + s := newTestServer(t, conf) + defer s.closeDatabases() + + // Create user without tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + + // Publish a message (anonymous user) + rr := request(t, s, "POST", "/mytopic", "hi", nil) + require.Equal(t, 200, rr.Code) + + // Publish a message (non-tier user) + rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // User stats (anonymous user) + rr = request(t, s, "GET", "/v1/account", "", nil) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) + + // User stats (non-tier user) + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) +} + +func TestServer_SubscriberRateLimiting_Success(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor + subscriber1Fn := func(r *http.Request) { + r.RemoteAddr = "1.2.3.4" + } + rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ + "Rate-Topics": "subscriber1topic", + }, subscriber1Fn) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String()) + + // "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) + subscriber2Fn := func(r *http.Request) { + r.RemoteAddr = "8.7.7.1" + } + rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String()) + + // Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the + // GET request before is also counted towards the request limiter. + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 429, rr.Code) + + // Publish another 2 messages to "up012345678912" as visitor 9.9.9.9 + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor! + } + rr = request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 429, rr.Code) + + // Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though + // VisitorRequestLimitBurst is 3. That means it's working. + + // Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter + // by publishing another 3 requests from it. + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/some-other-topic", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/some-other-topic", "some message", nil) + require.Equal(t, 429, rr.Code) +} + +func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = false + s := newTestServer(t, c) + + // Subscriber rate limiting is disabled! + + // Registering visitor 1.2.3.4 to topic has no effect + rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ + "Rate-Topics": "subscriber1topic", + }, func(r *http.Request) { + r.RemoteAddr = "1.2.3.4" + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Nil(t, s.topics["subscriber1topic"].rateVisitor) + + // Registering visitor 8.7.7.1 to topic has no effect + rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { + r.RemoteAddr = "8.7.7.1" + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Nil(t, s.topics["up012345678912"].rateVisitor) + + // Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9 + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 429, rr.Code) + rr = request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 429, rr.Code) +} + +func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // "Register" 5 different UnifiedPush visitors + for i := 0; i < 5; i++ { + subscriberFn := func(r *http.Request) { + r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1) + } + rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn) + require.Equal(t, 200, rr.Code) + } + + // Publish 2 messages per topic + for i := 0; i < 5; i++ { + for j := 0; j < 2; j++ { + rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil) + require.Equal(t, 200, rr.Code) + } + } +} + +func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // "Register" 5 different UnifiedPush visitors + for i := 0; i < 5; i++ { + rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) { + r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1) + }) + require.Equal(t, 200, rr.Code) + } + + // Publish 2 messages per topic + for i := 0; i < 5; i++ { + notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i) + for j := 0; j < 2; j++ { + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + } + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 429, response.Code, notification) + require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code) + } +} + +func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // "Register" rate visitor + subscriberFn := func(r *http.Request) { + r.RemoteAddr = "1.2.3.4" + } + rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ + "rate-topics": "mytopic", + }, subscriberFn) + require.Equal(t, 200, rr.Code) + require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String()) + require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) + + // Publish message, observe rate visitor tokens being decreased + response := request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) + require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value()) + require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) + + // Expire visitor + s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour) + s.pruneVisitors() + + // Publish message again, observe that rateVisitor is not used anymore and is reset + response = request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) + require.Nil(t, s.topics["mytopic"].rateVisitor) + require.Nil(t, s.visitors["ip:1.2.3.4"]) +} + +func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // Create some ACLs + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 5, + })) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("ben", "test")) + require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite)) + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite)) + + // Set rate visitor as user "phil" on topic + // - "reserved-for-phil": Allowed, because I am the owner + // - "public_topic": Allowed, because it has read-write permissions for everyone + // - "announcements": NOT allowed, because it has read-only permissions for everyone + rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "Rate-Topics": "reserved-for-phil,public_topic,announcements", + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name) + require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name) + require.Nil(t, s.topics["announcements"].rateVisitor) + + // Set rate visitor as user "ben" on topic + // - "reserved-for-phil": NOT allowed, because I am not the owner + // - "public_topic": Allowed, because it has read-write permissions for everyone + // - "announcements": Allowed, because I have read-write permissions + rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + "Rate-Topics": "reserved-for-phil,public_topic,announcements", + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name) + require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name) + require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name) +} + +func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionReadWrite + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + + // Create some ACLs + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) + + // Set rate visitor as ip:1.2.3.4 on topic + // - "up123456789012": Allowed, because no ACLs and nobody owns the topic + // - "announcements": NOT allowed, because it has read-only permissions for everyone + rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) { + r.RemoteAddr = "1.2.3.4" + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String()) + require.Nil(t, s.topics["announcements"].rateVisitor) +} + +func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { + c := newTestConfig(t) + c.ManagerInterval = 2 * time.Second + s := newTestServer(t, c) + + // Publish some messages, and get stats + for i := 0; i < 5; i++ { + response := request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + } + require.Equal(t, int64(5), s.messages) + require.Equal(t, []int64{0}, s.messagesHistory) + + response := request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":5,"messages_rate":0}`+"\n", response.Body.String()) + + // Run manager and see message history update + s.execManager() + require.Equal(t, []int64{0, 5}, s.messagesHistory) + + response = request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":5,"messages_rate":2.5}`+"\n", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second + + // Publish some more messages + for i := 0; i < 10; i++ { + response := request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + } + require.Equal(t, int64(15), s.messages) + require.Equal(t, []int64{0, 5}, s.messagesHistory) + + response = request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":15,"messages_rate":2.5}`+"\n", response.Body.String()) // Rate did not update yet + + // Run manager and see message history update + s.execManager() + require.Equal(t, []int64{0, 5, 15}, s.messagesHistory) + + response = request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":15,"messages_rate":3.75}`+"\n", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second +} + +func TestServer_MessageHistoryMaxSize(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + for i := 0; i < 20; i++ { + s.messages = int64(i) + s.execManager() + } + require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory) +} + +func TestServer_MessageCountPersistence(t *testing.T) { + c := newTestConfig(t) + s := newTestServer(t, c) + s.messages = 1234 + s.execManager() + waitFor(t, func() bool { + messages, err := s.messageCache.Stats() + require.Nil(t, err) + return messages == 1234 + }) + + s = newTestServer(t, c) + require.Equal(t, int64(1234), s.messages) +} + +func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ + "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", + "X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=", + "X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=", + "X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=", + "X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=", + "X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "🇩🇪", m.Message) + require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title) + require.Equal(t, "some ättachment.txt", m.Attachment.Name) + require.Equal(t, "🇩🇪", m.Tags[0]) + require.Equal(t, "ntfy 很棒", m.Tags[1]) + require.Equal(t, "https://💩.la", m.Click) + require.Equal(t, "Mettre à jour", m.Actions[0].Label) + require.Equal(t, "http", m.Actions[1].Action) + require.Equal(t, "这是一个标签", m.Actions[1].Label) + require.Equal(t, "https://💩.la", m.Actions[1].URL) +} + +func TestServer_UpstreamBaseURL_Success(t *testing.T) { + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) + + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) +} + +func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path) + require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization")) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + c.UpstreamAccessToken = "tk_1234567890" + s := newTestServer(t, c) + + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) +} + +func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("UnifiedPush messages should not be forwarded") + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) + + // Send UP message, this should not forward to upstream server + response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + + // Forwarding is done asynchronously, so wait a bit. + // This ensures that the t.Fatal above is actually not triggered. + time.Sleep(500 * time.Millisecond) } func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") + conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" conf.AttachmentCacheDir = t.TempDir() return conf } +func configureAuth(t *testing.T, conf *Config) *Config { + conf.AuthFile = filepath.Join(t.TempDir(), "user.db") + conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot + return conf +} + +func newTestConfigWithAuthFile(t *testing.T) *Config { + conf := newTestConfig(t) + conf = configureAuth(t, conf) + return conf +} + +func newTestConfigWithWebPush(t *testing.T) *Config { + conf := newTestConfig(t) + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + require.Nil(t, err) + conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db") + conf.WebPushEmailAddress = "testing@example.com" + conf.WebPushPrivateKey = privateKey + conf.WebPushPublicKey = publicKey + return conf +} + func newTestServer(t *testing.T, config *Config) *Server { server, err := New(config) - if err != nil { - t.Fatal(err) - } + require.Nil(t, err) return server } -func request(t *testing.T, s *Server, method, url, body string, headers map[string]string) *httptest.ResponseRecorder { +func request(t *testing.T, s *Server, method, url, body string, headers map[string]string, fn ...func(r *http.Request)) *httptest.ResponseRecorder { rr := httptest.NewRecorder() - req, err := http.NewRequest(method, url, strings.NewReader(body)) + r, err := http.NewRequest(method, url, strings.NewReader(body)) if err != nil { t.Fatal(err) } - req.RemoteAddr = "9.9.9.9" // Used for tests + r.RemoteAddr = "9.9.9.9" // Used for tests for k, v := range headers { - req.Header.Set(k, v) + r.Header.Set(k, v) } - s.handle(rr, req) + for _, f := range fn { + f(r) + } + s.handle(rr, r) return rr } @@ -1269,11 +2755,11 @@ func subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorde done <- true }() cancelAndWaitForDone := func() { - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) cancel() <-done } - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) return cancelAndWaitForDone } @@ -1298,18 +2784,25 @@ func toHTTPError(t *testing.T, s string) *errHTTP { return &e } -func firebaseServiceAccountFile(t *testing.T) string { - if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" { - return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") - } else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" { - filename := filepath.Join(t.TempDir(), "firebase.json") - require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600)) - return filename +func readAll(t *testing.T, rc io.ReadCloser) string { + b, err := io.ReadAll(rc) + if err != nil { + t.Fatal(err) } - t.SkipNow() - return "" + return string(b) } -func basicAuth(s string) string { - return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s))) +func waitFor(t *testing.T, f func() bool) { + waitForWithMaxWait(t, 5*time.Second, f) +} + +func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) { + start := time.Now() + for time.Since(start) < maxWait { + if f() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack())) } diff --git a/server/server_twilio.go b/server/server_twilio.go new file mode 100644 index 00000000..231436a3 --- /dev/null +++ b/server/server_twilio.go @@ -0,0 +1,176 @@ +package server + +import ( + "bytes" + "encoding/xml" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + twilioCallFormat = ` + + + + You have a message from notify on topic %s. Message: + + %s + + End of message. + + This message was sent by user %s. It will be repeated three times. + To unsubscribe from calls like this, remove your phone number in the notify web app. + + + Goodbye. +` +) + +// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified +// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. +// If the user is anonymous, it will return an error. +func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { + if u == nil { + return "", errHTTPBadRequestAnonymousCallsNotAllowed + } + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if len(phoneNumbers) == 0 { + return "", errHTTPBadRequestPhoneNumberNotVerified + } + if toBool(phoneNumber) { + return phoneNumbers[0], nil + } else if util.Contains(phoneNumbers, phoneNumber) { + return phoneNumber, nil + } + for _, p := range phoneNumbers { + if p == phoneNumber { + return phoneNumber, nil + } + } + return "", errHTTPBadRequestPhoneNumberNotVerified +} + +// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message. +// Failures will be logged, but not returned to the caller. +func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { + u, sender := v.User(), m.Sender.String() + if u != nil { + sender = u.Name + } + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) + data := url.Values{} + data.Set("From", s.config.TwilioPhoneNumber) + data.Set("To", to) + data.Set("Twiml", body) + ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request") + response, err := s.callPhoneInternal(data) + if err != nil { + ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request") + minc(metricCallsMadeFailure) + return + } + ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response") + minc(metricCallsMadeSuccess) +} + +func (s *Server) callPhoneInternal(data url.Values) (string, error) { + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + response, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(response), nil +} + +func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification") + data := url.Values{} + data.Set("To", phoneNumber) + data.Set("Channel", channel) + requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + response, err := io.ReadAll(resp.Body) + if err != nil { + ev.Err(err).Warn("Error sending Twilio phone verification request") + return err + } + ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response") + return nil +} + +func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") + data := url.Values{} + data.Set("To", phoneNumber) + data.Set("Code", code) + requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } else if resp.StatusCode != http.StatusOK { + if ev.IsTrace() { + response, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + ev.Field("twilio_response", string(response)) + } + ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode) + if resp.StatusCode == http.StatusNotFound { + return errHTTPGonePhoneVerificationExpired + } + return errHTTPInternalError + } + response, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if ev.IsTrace() { + ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") + } else if ev.IsDebug() { + ev.Debug("Received successful Twilio phone verification response") + } + return nil +} + +func xmlEscapeText(text string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(text)) + return buf.String() +} diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go new file mode 100644 index 00000000..d6877527 --- /dev/null +++ b/server/server_twilio_test.go @@ -0,0 +1,264 @@ +package server + +import ( + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { + var called, verified atomic.Bool + var code atomic.Pointer[string] + twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + if r.URL.Path == "/v2/Services/VA1234567890/Verifications" { + if code.Load() != nil { + t.Fatal("Should be only called once") + } + require.Equal(t, "Channel=sms&To=%2B12223334444", string(body)) + code.Store(util.String("123456")) + } else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" { + if verified.Load() { + t.Fatal("Should be only called once") + } + require.Equal(t, "Code=123456&To=%2B12223334444", string(body)) + verified.Store(true) + } else { + t.Fatal("Unexpected path:", r.URL.Path) + } + })) + defer twilioVerifyServer.Close() + twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioCallsServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioVerifyBaseURL = twilioVerifyServer.URL + c.TwilioCallsBaseURL = twilioCallsServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + c.TwilioVerifyService = "VA1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + + // Send verification code for phone number + response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return *code.Load() == "123456" + }) + + // Add phone number with code + response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return verified.Load() + }) + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+12223334444", phoneNumbers[0]) + + // Do the thing + response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) + + // Remove the phone number + response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + + // Verify the phone number is gone from the DB + phoneNumbers, err = s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) +} + +func TestServer_Twilio_Call_Success(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", // <<<------ + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "http://dummy.invalid" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+invalid", + }) + require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_Call_Anonymous(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+123123", + }) + require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_Call_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+1234", + }) + require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) +} diff --git a/server/server_webpush.go b/server/server_webpush.go new file mode 100644 index 00000000..a0e33af5 --- /dev/null +++ b/server/server_webpush.go @@ -0,0 +1,171 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "github.com/SherClockHolmes/webpush-go" +) + +const ( + webPushTopicSubscribeLimit = 50 +) + +var ( + webPushAllowedEndpointsPatterns = []string{ + "https://*.google.com/", + "https://*.googleapis.com/", + "https://*.mozilla.com/", + "https://*.mozaws.net/", + "https://*.windows.com/", + "https://*.microsoft.com/", + "https://*.apple.com/", + } + webPushAllowedEndpointsRegex *regexp.Regexp +) + +func init() { + for i, pattern := range webPushAllowedEndpointsPatterns { + webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+") + } + allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|")) + webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns) +} + +func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" { + return errHTTPBadRequestWebPushSubscriptionInvalid + } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) { + return errHTTPBadRequestWebPushEndpointUnknown + } else if len(req.Topics) > webPushTopicSubscribeLimit { + return errHTTPBadRequestWebPushTopicCountTooHigh + } + topics, err := s.topicsFromIDs(req.Topics...) + if err != nil { + return err + } + if s.userManager != nil { + u := v.User() + for _, t := range topics { + if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil { + logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) + return errHTTPForbidden.With(t) + } + } + } + if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error { + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil || req.Endpoint == "" { + return errHTTPBadRequestWebPushSubscriptionInvalid + } + if err := s.webPush.RemoveSubscriptionsByEndpoint(req.Endpoint); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { + subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic) + if err != nil { + logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages") + return + } + log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions)) + payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m)) + if err != nil { + log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload") + return + } + for _, subscription := range subscriptions { + if err := s.sendWebPushNotification(subscription, payload, v, m); err != nil { + log.Tag(tagWebPush).Err(err).With(v, m, subscription).Warn("Unable to publish web push message") + } + } +} + +func (s *Server) pruneAndNotifyWebPushSubscriptions() { + if s.config.WebPushPublicKey == "" { + return + } + go func() { + if err := s.pruneAndNotifyWebPushSubscriptionsInternal(); err != nil { + log.Tag(tagWebPush).Err(err).Warn("Unable to prune or notify web push subscriptions") + } + }() +} + +func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error { + // Expire old subscriptions + if err := s.webPush.RemoveExpiredSubscriptions(s.config.WebPushExpiryDuration); err != nil { + return err + } + // Notify subscriptions that will expire soon + subscriptions, err := s.webPush.SubscriptionsExpiring(s.config.WebPushExpiryWarningDuration) + if err != nil { + return err + } else if len(subscriptions) == 0 { + return nil + } + payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload()) + if err != nil { + return err + } + warningSent := make([]*webPushSubscription, 0) + for _, subscription := range subscriptions { + if err := s.sendWebPushNotification(subscription, payload); err != nil { + log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning") + continue + } + warningSent = append(warningSent, subscription) + } + if err := s.webPush.MarkExpiryWarningSent(warningSent); err != nil { + return err + } + log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions)) + return nil +} + +func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error { + log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message") + payload := &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{ + Auth: sub.Auth, + P256dh: sub.P256dh, + }, + } + resp, err := webpush.SendNotification(message, payload, &webpush.Options{ + Subscriber: s.config.WebPushEmailAddress, + VAPIDPublicKey: s.config.WebPushPublicKey, + VAPIDPrivateKey: s.config.WebPushPrivateKey, + Urgency: webpush.UrgencyHigh, // iOS requires this to ensure delivery + TTL: int(s.config.CacheDuration.Seconds()), + }) + if err != nil { + log.Tag(tagWebPush).With(sub).With(contexters...).Err(err).Debug("Unable to publish web push message, removing endpoint") + if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil { + return err + } + return err + } + if (resp.StatusCode < 200 || resp.StatusCode > 299) && resp.StatusCode != 429 { + log.Tag(tagWebPush).With(sub).With(contexters...).Field("response_code", resp.StatusCode).Debug("Unable to publish web push message, unexpected response") + if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil { + return err + } + return errHTTPInternalErrorWebPushUnableToPublish.With(sub).With(contexters...) + } + return nil +} diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go new file mode 100644 index 00000000..16e02cc8 --- /dev/null +++ b/server/server_webpush_test.go @@ -0,0 +1,256 @@ +package server + +import ( + "encoding/json" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" +) + +const ( + testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" +) + +func TestServer_WebPush_Disabled(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 404, response.Code) +} + +func TestServer_WebPush_TopicAdd(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "") +} + +func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String()) +} + +func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + topicList := make([]string, 51) + for i := range topicList { + topicList[i] = util.RandomString(5) + } + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String()) +} + +func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + addSubscription(t, s, testWebPushEndpoint, "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_Delete(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + addSubscription(t, s, testWebPushEndpoint, "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) + + response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) + + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.True(t, strings.HasPrefix(subs[0].UserID, "u_")) +} + +func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 403, response.Code) + + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + s := newTestServer(t, config) + + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 1) + + request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + // should've been deleted with the account + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_Publish(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/push-receive", r.URL.Path) + require.Equal(t, "high", r.Header.Get("Urgency")) + require.Equal(t, "", r.Header.Get("Topic")) + received.Store(true) + })) + defer pushService.Close() + + addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") + request(t, s, "POST", "/test-topic", "web push test", nil) + + waitFor(t, func() bool { + return received.Load() + }) +} + +func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + w.WriteHeader(http.StatusGone) + received.Store(true) + })) + defer pushService.Close() + + addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc") + requireSubscriptionCount(t, s, "test-topic", 1) + requireSubscriptionCount(t, s, "test-topic-abc", 1) + + request(t, s, "POST", "/test-topic", "web push test", nil) + + waitFor(t, func() bool { + return received.Load() + }) + + // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint + + requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic-abc", 0) +} + +func TestServer_WebPush_Expiry(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + w.WriteHeader(200) + w.Write([]byte(``)) + received.Store(true) + })) + defer pushService.Close() + + addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) + + _, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix()) + require.Nil(t, err) + + s.pruneAndNotifyWebPushSubscriptions() + requireSubscriptionCount(t, s, "test-topic", 1) + + waitFor(t, func() bool { + return received.Load() + }) + + _, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix()) + require.Nil(t, err) + + s.pruneAndNotifyWebPushSubscriptions() + waitFor(t, func() bool { + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + return len(subs) == 0 + }) +} + +func payloadForTopics(t *testing.T, topics []string, endpoint string) string { + topicsJSON, err := json.Marshal(topics) + require.Nil(t, err) + + return fmt.Sprintf(`{ + "topics": %s, + "endpoint": "%s", + "p256dh": "p256dh-key", + "auth": "auth-key" + }`, topicsJSON, endpoint) +} + +func addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) { + require.Nil(t, s.webPush.UpsertSubscription(endpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", "u_123", netip.MustParseAddr("1.2.3.4"), topics)) // Test auth and p256dh +} + +func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) { + subs, err := s.webPush.SubscriptionsForTopic(topic) + require.Nil(t, err) + require.Len(t, subs, expectedLength) +} diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 15f004c1..4c0d263e 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -4,33 +4,76 @@ import ( _ "embed" // required by go:embed "encoding/json" "fmt" - "heckel.io/ntfy/util" "mime" "net" "net/smtp" "strings" + "sync" "time" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" ) type mailer interface { - Send(from, to string, m *message) error + Send(v *visitor, m *message, to string) error + Counts() (total int64, success int64, failure int64) } type smtpSender struct { - config *Config + config *Config + success int64 + failure int64 + mu sync.Mutex } -func (s *smtpSender) Send(senderIP, to string, m *message) error { - host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) +func (s *smtpSender) Send(v *visitor, m *message, to string) error { + return s.withCount(v, m, func() error { + host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) + if err != nil { + return err + } + message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m) + if err != nil { + return err + } + var auth smtp.Auth + if s.config.SMTPSenderUser != "" { + auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) + } + ev := logvm(v, m). + Tag(tagEmail). + Fields(log.Context{ + "email_via": s.config.SMTPSenderAddr, + "email_user": s.config.SMTPSenderUser, + "email_to": to, + }) + if ev.IsTrace() { + ev.Field("email_body", message).Trace("Sending email") + } else if ev.IsDebug() { + ev.Debug("Sending email") + } + return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) + }) +} + +func (s *smtpSender) Counts() (total int64, success int64, failure int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.success + s.failure, s.success, s.failure +} + +func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error { + err := fn() + s.mu.Lock() + defer s.mu.Unlock() if err != nil { - return err + logvm(v, m).Err(err).Debug("Sending mail failed") + s.failure++ + } else { + s.success++ } - message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m) - if err != nil { - return err - } - auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) - return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) + return err } func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) { @@ -89,31 +132,23 @@ This message was sent by {ip} at {time} via {topicURL}` } var ( - //go:embed "mailer_emoji.json" + //go:embed "mailer_emoji_map.json" emojisJSON string ) -type emoji struct { - Emoji string `json:"emoji"` - Aliases []string `json:"aliases"` -} - func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojis []emoji - if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil { + var emojiMap map[string]string + if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { return nil, nil, err } tagsOut = make([]string, 0) emojisOut = make([]string, 0) -nextTag: - for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map - for _, e := range emojis { - if util.InStringList(e.Aliases, t) { - emojisOut = append(emojisOut, e.Emoji) - continue nextTag - } + for _, t := range tags { + if emoji, ok := emojiMap[t]; ok { + emojisOut = append(emojisOut, emoji) + } else { + tagsOut = append(tagsOut, t) } - tagsOut = append(tagsOut, t) } return } diff --git a/server/smtp_server.go b/server/smtp_server.go index 689deaf3..b9fbe6ee 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -2,11 +2,17 @@ package server import ( "bytes" + "encoding/base64" "errors" + "fmt" "github.com/emersion/go-smtp" "io" "mime" "mime/multipart" + "mime/quotedprintable" + "net" + "net/http" + "net/http/httptest" "net/mail" "strings" "sync" @@ -17,56 +23,67 @@ var ( errInvalidAddress = errors.New("invalid address") errInvalidTopic = errors.New("invalid topic") errTooManyRecipients = errors.New("too many recipients") + errMultipartNestedTooDeep = errors.New("multipart message nested too deep") errUnsupportedContentType = errors.New("unsupported content type") ) +const ( + maxMultipartDepth = 2 +) + // smtpBackend implements SMTP server methods. type smtpBackend struct { config *Config - sub subscriber + handler func(http.ResponseWriter, *http.Request) success int64 failure int64 mu sync.Mutex } -func newMailBackend(conf *Config, sub subscriber) *smtpBackend { +var _ smtp.Backend = (*smtpBackend)(nil) +var _ smtp.Session = (*smtpSession)(nil) + +func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend { return &smtpBackend{ - config: conf, - sub: sub, + config: conf, + handler: handler, } } -func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { - return &smtpSession{backend: b}, nil +func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) { + logem(conn).Debug("Incoming mail") + return &smtpSession{backend: b, conn: conn}, nil } -func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { - return &smtpSession{backend: b}, nil -} - -func (b *smtpBackend) Counts() (success int64, failure int64) { +func (b *smtpBackend) Counts() (total int64, success int64, failure int64) { b.mu.Lock() defer b.mu.Unlock() - return b.success, b.failure + return b.success + b.failure, b.success, b.failure } // smtpSession is returned after EHLO. type smtpSession struct { backend *smtpBackend + conn *smtp.Conn topic string + token string mu sync.Mutex } -func (s *smtpSession) AuthPlain(username, password string) error { +func (s *smtpSession) AuthPlain(username, _ string) error { + logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username) return nil } -func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error { +func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { + logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from) return nil } func (s *smtpSession) Rcpt(to string) error { + logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to) return s.withFailCount(func() error { + token := "" conf := s.backend.config addressList, err := mail.ParseAddressList(to) if err != nil { @@ -78,18 +95,27 @@ func (s *smtpSession) Rcpt(to string) error { if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) { return errInvalidDomain } + // Remove @ntfy.sh from end of email to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain) if conf.SMTPServerAddrPrefix != "" { if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) { return errInvalidAddress } + // remove ntfy- from beginning of email to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix) } + // If email contains token, split topic and token + if strings.Contains(to, "+") { + parts := strings.Split(to, "+") + to = parts[0] + token = parts[1] + } if !topicRegex.MatchString(to) { return errInvalidTopic } s.mu.Lock() s.topic = to + s.token = token s.mu.Unlock() return nil }) @@ -102,11 +128,17 @@ func (s *smtpSession) Data(r io.Reader) error { if err != nil { return err } + ev := logem(s.conn) + if ev.IsTrace() { + ev.Field("smtp_data", string(b)).Trace("DATA") + } else if ev.IsDebug() { + ev.Field("smtp_data_len", len(b)).Debug("DATA") + } msg, err := mail.ReadMessage(bytes.NewReader(b)) if err != nil { return err } - body, err := readMailBody(msg) + body, err := readMailBody(msg.Body, msg.Header) if err != nil { return err } @@ -128,16 +160,46 @@ func (s *smtpSession) Data(r io.Reader) error { m.Message = m.Title // Flip them, this makes more sense m.Title = "" } - if err := s.backend.sub(m); err != nil { + if err := s.publishMessage(m); err != nil { return err } s.backend.mu.Lock() s.backend.success++ s.backend.mu.Unlock() + minc(metricEmailsReceivedSuccess) return nil }) } +func (s *smtpSession) publishMessage(m *message) error { + // Extract remote address (for rate limiting) + remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String()) + if err != nil { + remoteAddr = s.conn.Conn().RemoteAddr().String() + } + // Call HTTP handler with fake HTTP request + url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) + req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) + req.RequestURI = "/" + m.Topic // just for the logs + req.RemoteAddr = remoteAddr // rate limiting!! + req.Header.Set("X-Forwarded-For", remoteAddr) + if err != nil { + return err + } + if m.Title != "" { + req.Header.Set("Title", m.Title) + } + if s.token != "" { + req.Header.Add("Authorization", "Bearer "+s.token) + } + rr := httptest.NewRecorder() + s.backend.handler(rr, req) + if rr.Code != http.StatusOK { + return errors.New("error: " + rr.Body.String()) + } + return nil +} + func (s *smtpSession) Reset() { s.mu.Lock() s.topic = "" @@ -153,43 +215,63 @@ func (s *smtpSession) withFailCount(fn func() error) error { s.backend.mu.Lock() defer s.backend.mu.Unlock() if err != nil { + // Almost all of these errors are parse errors, and user input errors. + // We do not want to spam the log with WARN messages. + logem(s.conn).Err(err).Debug("Incoming mail error") s.backend.failure++ + minc(metricEmailsReceivedFailure) } return err } -func readMailBody(msg *mail.Message) (string, error) { - contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) +func readMailBody(body io.Reader, header mail.Header) (string, error) { + if header.Get("Content-Type") == "" { + return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) + } + contentType, params, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil { return "", err } - if contentType == "text/plain" { - body, err := io.ReadAll(msg.Body) - if err != nil { - return "", err - } - return string(body), nil - } - if strings.HasPrefix(contentType, "multipart/") { - mr := multipart.NewReader(msg.Body, params["boundary"]) - for { - part, err := mr.NextPart() - if err != nil { // may be io.EOF - return "", err - } - partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")) - if err != nil { - return "", err - } - if partContentType != "text/plain" { - continue - } - body, err := io.ReadAll(part) - if err != nil { - return "", err - } - return string(body), nil - } + if strings.ToLower(contentType) == "text/plain" { + return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) + } else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") { + return readMultipartMailBody(body, params, 0) } return "", errUnsupportedContentType } + +func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) { + if depth >= maxMultipartDepth { + return "", errMultipartNestedTooDeep + } + mr := multipart.NewReader(body, params["boundary"]) + for { + part, err := mr.NextPart() + if err != nil { // may be io.EOF + return "", err + } + partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return "", err + } + if strings.ToLower(partContentType) == "text/plain" { + return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) + } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") { + return readMultipartMailBody(part, partParams, depth+1) + } + // Continue with next part + } +} + +func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) { + if strings.ToLower(transferEncoding) == "base64" { + reader = base64.NewDecoder(base64.StdEncoding, reader) + } else if strings.ToLower(transferEncoding) == "quoted-printable" { + reader = quotedprintable.NewReader(reader) + } + body, err := io.ReadAll(reader) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index c954d124..7e1d29d9 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -1,14 +1,23 @@ package server import ( + "bufio" "github.com/emersion/go-smtp" "github.com/stretchr/testify/require" + "io" + "net" + "net/http" "strings" "testing" + "time" ) func TestSmtpBackend_Multipart(t *testing.T) { - email := `MIME-Version: 1.0 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +MIME-Version: 1.0 Date: Tue, 28 Dec 2021 00:30:10 +0100 Message-ID: Subject: and one more @@ -26,21 +35,25 @@ Content-Type: text/html; charset="UTF-8"
what's up

---000000000000f3320b05d42915c9--` - _, backend := newTestBackend(t, func(m *message) error { - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "and one more", m.Title) - require.Equal(t, "what's up", m.Message) - return nil +--000000000000f3320b05d42915c9-- +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "and one more", r.Header.Get("Title")) + require.Equal(t, "what's up", readAll(t, r.Body)) }) - session, _ := backend.AnonymousLogin(nil) - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) - require.Nil(t, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } func TestSmtpBackend_MultipartNoBody(t *testing.T) { - email := `MIME-Version: 1.0 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-emailtest@ntfy.sh +DATA +MIME-Version: 1.0 Date: Tue, 28 Dec 2021 01:33:34 +0100 Message-ID: Subject: This email has a subject but no body @@ -58,21 +71,25 @@ Content-Type: text/html; charset="UTF-8"

---000000000000bcf4a405d429f8d4--` - _, backend := newTestBackend(t, func(m *message) error { - require.Equal(t, "emailtest", m.Topic) - require.Equal(t, "", m.Title) // We flipped message and body - require.Equal(t, "This email has a subject but no body", m.Message) - return nil +--000000000000bcf4a405d429f8d4-- +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/emailtest", r.URL.Path) + require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body + require.Equal(t, "This email has a subject but no body", readAll(t, r.Body)) }) - session, _ := backend.AnonymousLogin(nil) - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh")) - require.Nil(t, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } func TestSmtpBackend_Plaintext(t *testing.T) { - email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: mytopic@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 Message-ID: Subject: and one more From: Phil @@ -80,41 +97,68 @@ To: mytopic@ntfy.sh Content-Type: text/plain; charset="UTF-8" what's up +. ` - conf, backend := newTestBackend(t, func(m *message) error { - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "and one more", m.Title) - require.Equal(t, "what's up", m.Message) - return nil + s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "and one more", r.Header.Get("Title")) + require.Equal(t, "what's up", readAll(t, r.Body)) }) conf.SMTPServerAddrPrefix = "" - session, _ := backend.AnonymousLogin(nil) - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) - require.Nil(t, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) { + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: mytopic@ntfy.sh +DATA +Subject: Very short mail + +what's up +. +` + s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Very short mail", r.Header.Get("Title")) + require.Equal(t, "what's up", readAll(t, r.Body)) + }) + conf.SMTPServerAddrPrefix = "" + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) { - email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?= From: Phil To: ntfy-mytopic@ntfy.sh Content-Type: text/plain; charset="UTF-8" what's up +. ` - _, backend := newTestBackend(t, func(m *message) error { - require.Equal(t, "Three santas 🎅🎅🎅", m.Title) - return nil + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title")) }) - session, _ := backend.AnonymousLogin(nil) - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) - require.Nil(t, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) { - email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: mytopic@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 Message-ID: Subject: and one more From: Phil @@ -122,7 +166,7 @@ To: mytopic@ntfy.sh Content-Type: text/plain; charset="UTF-8" you know this is a string. -it's a long string. +it's a long string. it's supposed to be longer than the max message length which is 4096 bytes, it used to be 512 bytes, but I increased that for the UnifiedPush support @@ -133,62 +177,63 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB that should do it +. ` - conf, backend := newTestBackend(t, func(m *message) error { + s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { expected := `you know this is a string. -it's a long string. +it's a long string. it's supposed to be longer than the max message length which is 4096 bytes, it used to be 512 bytes, but I increased that for the UnifiedPush support @@ -199,69 +244,104 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... -...................................................................... +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp +pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB -BBBBBBBBBBBBBBBBBBBBBBBB` +BBBBBBBBBBBBBBBBBBBBBBBBB` require.Equal(t, 4096, len(expected)) // Sanity check - require.Equal(t, expected, m.Message) - return nil + require.Equal(t, expected, readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + conf.SMTPServerAddrPrefix = "" + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_Plaintext_QuotedPrintable(t *testing.T) { + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: mytopic@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: mytopic@ntfy.sh +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +what's +=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0) +=3D=3D=3D=3D=3D +up +. +` + s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "and one more", r.Header.Get("Title")) + require.Equal(t, `what's +à&é"'(-è_çà) +===== +up`, readAll(t, r.Body)) }) conf.SMTPServerAddrPrefix = "" - session, _ := backend.AnonymousLogin(nil) - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) - require.Nil(t, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } func TestSmtpBackend_Unsupported(t *testing.T) { - email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 Message-ID: Subject: and one more From: Phil @@ -269,22 +349,297 @@ To: mytopic@ntfy.sh Content-Type: text/SOMETHINGELSE what's up +. ` - conf, backend := newTestBackend(t, func(m *message) error { - return nil + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("This should not be called") }) - conf.SMTPServerAddrPrefix = "" - session, _ := backend.Login(nil, "user", "pass") - require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) - require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) - require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email))) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type") } -func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) { - conf := newTestConfig(t) +func TestSmtpBackend_InvalidAddress(t *testing.T) { + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: unsupported@ntfy.sh +DATA +Date: Tue, 28 Dec 2021 00:30:10 +0100 +Subject: and one more +From: Phil +To: mytopic@ntfy.sh +Content-Type: text/plain + +what's up +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("This should not be called") + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address") +} + +func TestSmtpBackend_Base64Body(t *testing.T) { + email := `EHLO example.com +MAIL FROM: test@mydomain.me +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Content-Type: multipart/mixed; boundary="===============2138658284696597373==" +MIME-Version: 1.0 +Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local +From: =?utf-8?q?Robbie?= +To: test@mydomain.me +Date: Thu, 16 Feb 2023 01:04:00 -0000 +Message-ID: + +This is a multi-part message in MIME format. +--===============2138658284696597373== +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= + +--===============2138658284696597373== +Content-Type: text/html; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv +L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== + +--===============2138658284696597373==-- +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title")) + require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_MultipartQuotedPrintable(t *testing.T) { + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +MIME-Version: 1.0 +Date: Tue, 28 Dec 2021 00:30:10 +0100 +Message-ID: +Subject: and one more +From: Phil +To: ntfy-mytopic@ntfy.sh +Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9" + +--000000000000f3320b05d42915c9 +Content-Type: text/html; charset="UTF-8" + +html, ignore me + +--000000000000f3320b05d42915c9 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +what's +=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0) +=3D=3D=3D=3D=3D +up + +--000000000000f3320b05d42915c9-- +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "and one more", r.Header.Get("Title")) + require.Equal(t, `what's +à&é"'(-è_çà) +===== +up`, readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_NestedMultipartBase64(t *testing.T) { + email := `EHLO example.com +MAIL FROM: test@mydomain.me +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Content-Type: multipart/mixed; boundary="===============2138658284696597373==" +MIME-Version: 1.0 +Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local +From: =?utf-8?q?Robbie?= +To: test@mydomain.me +Date: Thu, 16 Feb 2023 01:04:00 -0000 +Message-ID: + +This is a multi-part message in MIME format. +--===============2138658284696597373== +Content-Type: multipart/alternative; boundary="===============2233989480071754745==" +MIME-Version: 1.0 + +--===============2233989480071754745== +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= + +--===============2233989480071754745== +Content-Type: text/html; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv +L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== + +--===============2233989480071754745==-- + +--===============2138658284696597373==-- +. +` + + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title")) + require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) { + email := `EHLO example.com +MAIL FROM: test@mydomain.me +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Content-Type: multipart/mixed; boundary="===============1==" +MIME-Version: 1.0 +Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local +From: =?utf-8?q?Robbie?= +To: test@mydomain.me +Date: Thu, 16 Feb 2023 01:04:00 -0000 +Message-ID: + +This is a multi-part message in MIME format. +--===============1== +Content-Type: multipart/alternative; boundary="===============2==" +MIME-Version: 1.0 + +--===============2== +Content-Type: multipart/alternative; boundary="===============3==" +MIME-Version: 1.0 + +--===============3== +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= + +--===============3== +Content-Type: text/html; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv +L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== + +--===============3==-- + +--===============2==-- + +--===============1==-- +. +` + + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("This should not be called") + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep") +} + +func TestSmtpBackend_PlaintextWithToken(t *testing.T) { + email := `EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh +DATA +Subject: Very short mail + +what's up +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Very short mail", r.Header.Get("Title")) + require.Equal(t, "Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2", r.Header.Get("Authorization")) + require.Equal(t, "what's up", readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +type smtpHandlerFunc func(http.ResponseWriter, *http.Request) + +func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) { + conf = newTestConfig(t) conf.SMTPServerListen = ":25" conf.SMTPServerDomain = "ntfy.sh" conf.SMTPServerAddrPrefix = "ntfy-" - backend := newMailBackend(conf, sub) - return conf, backend + backend := newMailBackend(conf, handler) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s = smtp.NewServer(backend) + s.Domain = conf.SMTPServerDomain + s.AllowInsecureAuth = true + go func() { + require.Nil(t, s.Serve(l)) + }() + c, err = net.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatal(err) + } + scanner = bufio.NewScanner(c) + return +} + +func writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) { + _, err := io.WriteString(conn, email) + require.Nil(t, err) + readUntilLine(t, conn, scanner, expectedLine) +} + +func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) { + cancelChan := make(chan bool) + go func() { + select { + case <-cancelChan: + case <-time.After(3 * time.Second): + conn.Close() + t.Error("Failed waiting for expected output") + } + }() + var output string + for scanner.Scan() { + text := scanner.Text() + if strings.TrimSpace(text) == expectedLine { + cancelChan <- true + return + } + output += text + "\n" + //fmt.Println(text) + } + t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output) } diff --git a/server/topic.go b/server/topic.go index 9badd7bd..3daac9a7 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1,39 +1,100 @@ package server import ( - "log" "math/rand" "sync" + "time" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" +) + +const ( + // topicExpungeAfter defines how long a topic is active before it is removed from memory. + // This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give + // time for more requests to come in, so that we can send a {"rejected":[""]} response back. + topicExpungeAfter = 16 * time.Hour ) // topic represents a channel to which subscribers can subscribe, and publishers // can publish a message type topic struct { ID string - subscribers map[int]subscriber - mu sync.Mutex + subscribers map[int]*topicSubscriber + rateVisitor *visitor + lastAccess time.Time + mu sync.RWMutex +} + +type topicSubscriber struct { + userID string // User ID associated with this subscription, may be empty + subscriber subscriber + cancel func() } // subscriber is a function that is called for every new message on a topic -type subscriber func(msg *message) error +type subscriber func(v *visitor, msg *message) error // newTopic creates a new topic func newTopic(id string) *topic { return &topic{ ID: id, - subscribers: make(map[int]subscriber), + subscribers: make(map[int]*topicSubscriber), + lastAccess: time.Now(), } } // Subscribe subscribes to this topic -func (t *topic) Subscribe(s subscriber) int { +func (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscriberID int) { t.mu.Lock() defer t.mu.Unlock() - subscriberID := rand.Int() - t.subscribers[subscriberID] = s + for i := 0; i < 5; i++ { // Best effort retry + subscriberID = rand.Int() + _, exists := t.subscribers[subscriberID] + if !exists { + break + } + } + t.subscribers[subscriberID] = &topicSubscriber{ + userID: userID, // May be empty + subscriber: s, + cancel: cancel, + } + t.lastAccess = time.Now() return subscriberID } +func (t *topic) Stale() bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.rateVisitor != nil && !t.rateVisitor.Stale() { + return false + } + return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter +} + +func (t *topic) LastAccess() time.Time { + t.mu.RLock() + defer t.mu.RUnlock() + return t.lastAccess +} + +func (t *topic) SetRateVisitor(v *visitor) { + t.mu.Lock() + defer t.mu.Unlock() + t.rateVisitor = v + t.lastAccess = time.Now() +} + +func (t *topic) RateVisitor() *visitor { + t.mu.Lock() + defer t.mu.Unlock() + if t.rateVisitor != nil && t.rateVisitor.Stale() { + t.rateVisitor = nil + } + return t.rateVisitor +} + // Unsubscribe removes the subscription from the list of subscribers func (t *topic) Unsubscribe(id int) { t.mu.Lock() @@ -42,22 +103,105 @@ func (t *topic) Unsubscribe(id int) { } // Publish asynchronously publishes to all subscribers -func (t *topic) Publish(m *message) error { +func (t *topic) Publish(v *visitor, m *message) error { go func() { - t.mu.Lock() - defer t.mu.Unlock() - for _, s := range t.subscribers { - if err := s(m); err != nil { - log.Printf("error publishing message to subscriber") + // We want to lock the topic as short as possible, so we make a shallow copy of the + // subscribers map here. Actually sending out the messages then doesn't have to lock. + subscribers := t.subscribersCopy() + if len(subscribers) > 0 { + logvm(v, m).Tag(tagPublish).Debug("Forwarding to %d subscriber(s)", len(subscribers)) + for _, s := range subscribers { + // We call the subscriber functions in their own Go routines because they are blocking, and + // we don't want individual slow subscribers to be able to block others. + go func(s subscriber) { + if err := s(v, m); err != nil { + logvm(v, m).Tag(tagPublish).Err(err).Warn("Error forwarding to subscriber") + } + }(s.subscriber) } + } else { + logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding") } + t.Keepalive() }() return nil } -// Subscribers returns the number of subscribers to this topic -func (t *topic) Subscribers() int { +// Stats returns the number of subscribers and last access to this topic +func (t *topic) Stats() (int, time.Time) { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.subscribers), t.lastAccess +} + +// Keepalive sets the last access time and ensures that Stale does not return true +func (t *topic) Keepalive() { t.mu.Lock() defer t.mu.Unlock() - return len(t.subscribers) + t.lastAccess = time.Now() +} + +// CancelSubscribersExceptUser calls the cancel function for all subscribers, forcing +func (t *topic) CancelSubscribersExceptUser(exceptUserID string) { + t.mu.Lock() + defer t.mu.Unlock() + for _, s := range t.subscribers { + if s.userID != exceptUserID { + t.cancelUserSubscriber(s) + } + } +} + +// CancelSubscriberUser kills the subscriber with the given user ID +func (t *topic) CancelSubscriberUser(userID string) { + t.mu.RLock() + defer t.mu.RUnlock() + for _, s := range t.subscribers { + if s.userID == userID { + t.cancelUserSubscriber(s) + return + } + } +} + +func (t *topic) cancelUserSubscriber(s *topicSubscriber) { + log. + Tag(tagSubscribe). + With(t). + Fields(log.Context{ + "user_id": s.userID, + }). + Debug("Canceling subscriber with user ID %s", s.userID) + s.cancel() +} + +func (t *topic) Context() log.Context { + t.mu.RLock() + defer t.mu.RUnlock() + fields := map[string]any{ + "topic": t.ID, + "topic_subscribers": len(t.subscribers), + "topic_last_access": util.FormatTime(t.lastAccess), + } + if t.rateVisitor != nil { + for k, v := range t.rateVisitor.Context() { + fields["topic_rate_"+k] = v + } + } + return fields +} + +// subscribersCopy returns a shallow copy of the subscribers map +func (t *topic) subscribersCopy() map[int]*topicSubscriber { + t.mu.Lock() + defer t.mu.Unlock() + subscribers := make(map[int]*topicSubscriber) + for k, sub := range t.subscribers { + subscribers[k] = &topicSubscriber{ + userID: sub.userID, + subscriber: sub.subscriber, + cancel: sub.cancel, + } + } + return subscribers } diff --git a/server/topic_test.go b/server/topic_test.go new file mode 100644 index 00000000..41a29cfd --- /dev/null +++ b/server/topic_test.go @@ -0,0 +1,92 @@ +package server + +import ( + "math/rand" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestTopic_CancelSubscribersExceptUser(t *testing.T) { + t.Parallel() + + subFn := func(v *visitor, msg *message) error { + return nil + } + canceled1 := atomic.Bool{} + cancelFn1 := func() { + canceled1.Store(true) + } + canceled2 := atomic.Bool{} + cancelFn2 := func() { + canceled2.Store(true) + } + to := newTopic("mytopic") + to.Subscribe(subFn, "", cancelFn1) + to.Subscribe(subFn, "u_phil", cancelFn2) + + to.CancelSubscribersExceptUser("u_phil") + require.True(t, canceled1.Load()) + require.False(t, canceled2.Load()) +} + +func TestTopic_CancelSubscribersUser(t *testing.T) { + t.Parallel() + + subFn := func(v *visitor, msg *message) error { + return nil + } + canceled1 := atomic.Bool{} + cancelFn1 := func() { + canceled1.Store(true) + } + canceled2 := atomic.Bool{} + cancelFn2 := func() { + canceled2.Store(true) + } + to := newTopic("mytopic") + to.Subscribe(subFn, "u_another", cancelFn1) + to.Subscribe(subFn, "u_phil", cancelFn2) + + to.CancelSubscriberUser("u_phil") + require.False(t, canceled1.Load()) + require.True(t, canceled2.Load()) +} + +func TestTopic_Keepalive(t *testing.T) { + t.Parallel() + + to := newTopic("mytopic") + to.lastAccess = time.Now().Add(-1 * time.Hour) + to.Keepalive() + require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2) + require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2) +} + +func TestTopic_Subscribe_DuplicateID(t *testing.T) { + t.Parallel() + to := newTopic("mytopic") + + // Fix random seed to force same number generation + rand.Seed(1) + a := rand.Int() + to.subscribers[a] = &topicSubscriber{ + userID: "a", + subscriber: nil, + cancel: func() {}, + } + + subFn := func(v *visitor, msg *message) error { + return nil + } + + // Force rand.Int to generate the same id once more + rand.Seed(1) + id := to.Subscribe(subFn, "b", func() {}) + res := to.subscribers[id] + + require.NotEqual(t, id, a) + require.Equal(t, "b", res.userID, "b") +} diff --git a/server/types.go b/server/types.go index 8c4f125b..5c423216 100644 --- a/server/types.go +++ b/server/types.go @@ -1,9 +1,14 @@ package server import ( - "heckel.io/ntfy/util" "net/http" + "net/netip" "time" + + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + + "git.zio.sh/astra/ntfy/v2/util" ) // List of possible events @@ -20,18 +25,41 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - Time int64 `json:"time"` // Unix time in seconds - Event string `json:"event"` // One of the above - Topic string `json:"topic"` - Priority int `json:"priority,omitempty"` - Tags []string `json:"tags,omitempty"` - Click string `json:"click,omitempty"` - Actions []*action `json:"actions,omitempty"` - Attachment *attachment `json:"attachment,omitempty"` - Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` - Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes + ID string `json:"id"` // Random message ID + Time int64 `json:"time"` // Unix time in seconds + Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) + Event string `json:"event"` // One of the above + Topic string `json:"topic"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Priority int `json:"priority,omitempty"` + Tags []string `json:"tags,omitempty"` + Click string `json:"click,omitempty"` + Icon string `json:"icon,omitempty"` + Actions []*action `json:"actions,omitempty"` + Attachment *attachment `json:"attachment,omitempty"` + PollID string `json:"poll_id,omitempty"` + ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown + Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes + Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting + User string `json:"-"` // UserID of the uploader, used to associated attachments +} + +func (m *message) Context() log.Context { + fields := map[string]any{ + "topic": m.Topic, + "message_id": m.ID, + "message_time": m.Time, + "message_event": m.Event, + "message_body_size": len(m.Message), + } + if m.Sender.IsValid() { + fields["message_sender"] = m.Sender.String() + } + if m.User != "" { + fields["message_user"] = m.User + } + return fields } type attachment struct { @@ -40,7 +68,6 @@ type attachment struct { Size int64 `json:"size,omitempty"` Expires int64 `json:"expires,omitempty"` URL string `json:"url"` - Owner string `json:"-"` // IP address of uploader, used for rate limiting } type action struct { @@ -71,10 +98,13 @@ type publishMessage struct { Priority int `json:"priority"` Tags []string `json:"tags"` Click string `json:"click"` + Icon string `json:"icon"` Actions []action `json:"actions"` Attach string `json:"attach"` + Markdown bool `json:"markdown"` Filename string `json:"filename"` Email string `json:"email"` + Call string `json:"call"` Delay string `json:"delay"` } @@ -84,14 +114,11 @@ type messageEncoder func(msg *message) (string, error) // newMessage creates a new message with the current timestamp func newMessage(event, topic, msg string) *message { return &message{ - ID: util.RandomString(messageIDLength), - Time: time.Now().Unix(), - Event: event, - Topic: topic, - Priority: 0, - Tags: nil, - Title: "", - Message: msg, + ID: util.RandomString(messageIDLength), + Time: time.Now().Unix(), + Event: event, + Topic: topic, + Message: msg, } } @@ -110,6 +137,13 @@ func newDefaultMessage(topic, msg string) *message { return newMessage(messageEvent, topic, msg) } +// newPollRequestMessage is a convenience method to create a poll request message +func newPollRequestMessage(topic, pollID string) *message { + m := newMessage(pollRequestEvent, topic, newMessageBody) + m.PollID = pollID + return m +} + func validMessageID(s string) bool { return util.ValidRandomString(s, messageIDLength) } @@ -153,6 +187,7 @@ var ( ) type queryFilter struct { + ID string Message string Title string Tags []string @@ -160,6 +195,7 @@ type queryFilter struct { } func parseQueryFilters(r *http.Request) (*queryFilter, error) { + idFilter := readParam(r, "x-id", "id") messageFilter := readParam(r, "x-message", "message", "m") titleFilter := readParam(r, "x-title", "title", "t") tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",") @@ -167,11 +203,12 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") { priority, err := util.ParsePriority(p) if err != nil { - return nil, err + return nil, errHTTPBadRequestPriorityInvalid } priorityFilter = append(priorityFilter, priority) } return &queryFilter{ + ID: idFilter, Message: messageFilter, Title: titleFilter, Tags: tagsFilter, @@ -182,22 +219,323 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { func (q *queryFilter) Pass(msg *message) bool { if msg.Event != messageEvent { return true // filters only apply to messages - } - if q.Message != "" && msg.Message != q.Message { + } else if q.ID != "" && msg.ID != q.ID { return false - } - if q.Title != "" && msg.Title != q.Title { + } else if q.Message != "" && msg.Message != q.Message { + return false + } else if q.Title != "" && msg.Title != q.Title { return false } messagePriority := msg.Priority if messagePriority == 0 { messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0) } - if len(q.Priority) > 0 && !util.InIntList(q.Priority, messagePriority) { + if len(q.Priority) > 0 && !util.Contains(q.Priority, messagePriority) { return false } - if len(q.Tags) > 0 && !util.InStringListAll(msg.Tags, q.Tags) { + if len(q.Tags) > 0 && !util.ContainsAll(msg.Tags, q.Tags) { return false } return true } + +type apiHealthResponse struct { + Healthy bool `json:"healthy"` +} + +type apiStatsResponse struct { + Messages int64 `json:"messages"` + MessagesRate float64 `json:"messages_rate"` // Average number of messages per second +} + +type apiUserAddRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Tier string `json:"tier"` + // Do not add 'role' here. We don't want to add admins via the API. +} + +type apiUserResponse struct { + Username string `json:"username"` + Role string `json:"role"` + Tier string `json:"tier,omitempty"` + Grants []*apiUserGrantResponse `json:"grants,omitempty"` +} + +type apiUserGrantResponse struct { + Topic string `json:"topic"` // This may be a pattern + Permission string `json:"permission"` +} + +type apiUserDeleteRequest struct { + Username string `json:"username"` +} + +type apiAccessAllowRequest struct { + Username string `json:"username"` + Topic string `json:"topic"` // This may be a pattern + Permission string `json:"permission"` +} + +type apiAccessResetRequest struct { + Username string `json:"username"` + Topic string `json:"topic"` +} + +type apiAccountCreateRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type apiAccountPasswordChangeRequest struct { + Password string `json:"password"` + NewPassword string `json:"new_password"` +} + +type apiAccountDeleteRequest struct { + Password string `json:"password"` +} + +type apiAccountTokenIssueRequest struct { + Label *string `json:"label"` + Expires *int64 `json:"expires"` // Unix timestamp +} + +type apiAccountTokenUpdateRequest struct { + Token string `json:"token"` + Label *string `json:"label"` + Expires *int64 `json:"expires"` // Unix timestamp +} + +type apiAccountTokenResponse struct { + Token string `json:"token"` + Label string `json:"label,omitempty"` + LastAccess int64 `json:"last_access,omitempty"` + LastOrigin string `json:"last_origin,omitempty"` + Expires int64 `json:"expires,omitempty"` // Unix timestamp +} + +type apiAccountPhoneNumberVerifyRequest struct { + Number string `json:"number"` + Channel string `json:"channel"` +} + +type apiAccountPhoneNumberAddRequest struct { + Number string `json:"number"` + Code string `json:"code"` // Only set when adding a phone number +} + +type apiAccountTier struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type apiAccountLimits struct { + Basis string `json:"basis,omitempty"` // "ip" or "tier" + Messages int64 `json:"messages"` + MessagesExpiryDuration int64 `json:"messages_expiry_duration"` + Emails int64 `json:"emails"` + Calls int64 `json:"calls"` + Reservations int64 `json:"reservations"` + AttachmentTotalSize int64 `json:"attachment_total_size"` + AttachmentFileSize int64 `json:"attachment_file_size"` + AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"` + AttachmentBandwidth int64 `json:"attachment_bandwidth"` +} + +type apiAccountStats struct { + Messages int64 `json:"messages"` + MessagesRemaining int64 `json:"messages_remaining"` + Emails int64 `json:"emails"` + EmailsRemaining int64 `json:"emails_remaining"` + Calls int64 `json:"calls"` + CallsRemaining int64 `json:"calls_remaining"` + Reservations int64 `json:"reservations"` + ReservationsRemaining int64 `json:"reservations_remaining"` + AttachmentTotalSize int64 `json:"attachment_total_size"` + AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"` +} + +type apiAccountReservation struct { + Topic string `json:"topic"` + Everyone string `json:"everyone"` +} + +type apiAccountBilling struct { + Customer bool `json:"customer"` + Subscription bool `json:"subscription"` + Status string `json:"status,omitempty"` + Interval string `json:"interval,omitempty"` + PaidUntil int64 `json:"paid_until,omitempty"` + CancelAt int64 `json:"cancel_at,omitempty"` +} + +type apiAccountResponse struct { + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []string `json:"phone_numbers,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` +} + +type apiAccountReservationRequest struct { + Topic string `json:"topic"` + Everyone string `json:"everyone"` +} + +type apiConfigResponse struct { + BaseURL string `json:"base_url"` + AppRoot string `json:"app_root"` + EnableLogin bool `json:"enable_login"` + EnableSignup bool `json:"enable_signup"` + EnablePayments bool `json:"enable_payments"` + EnableCalls bool `json:"enable_calls"` + EnableEmails bool `json:"enable_emails"` + EnableReservations bool `json:"enable_reservations"` + EnableWebPush bool `json:"enable_web_push"` + BillingContact string `json:"billing_contact"` + WebPushPublicKey string `json:"web_push_public_key"` + DisallowedTopics []string `json:"disallowed_topics"` +} + +type apiAccountBillingPrices struct { + Month int64 `json:"month"` + Year int64 `json:"year"` +} + +type apiAccountBillingTier struct { + Code string `json:"code,omitempty"` + Name string `json:"name,omitempty"` + Prices *apiAccountBillingPrices `json:"prices,omitempty"` + Limits *apiAccountLimits `json:"limits"` +} + +type apiAccountBillingSubscriptionCreateResponse struct { + RedirectURL string `json:"redirect_url"` +} + +type apiAccountBillingSubscriptionChangeRequest struct { + Tier string `json:"tier"` + Interval string `json:"interval"` +} + +type apiAccountBillingPortalRedirectResponse struct { + RedirectURL string `json:"redirect_url"` +} + +type apiAccountSyncTopicResponse struct { + Event string `json:"event"` +} + +type apiSuccessResponse struct { + Success bool `json:"success"` +} + +func newSuccessResponse() *apiSuccessResponse { + return &apiSuccessResponse{ + Success: true, + } +} + +type apiStripeSubscriptionUpdatedEvent struct { + ID string `json:"id"` + Customer string `json:"customer"` + Status string `json:"status"` + CurrentPeriodEnd int64 `json:"current_period_end"` + CancelAt int64 `json:"cancel_at"` + Items *struct { + Data []*struct { + Price *struct { + ID string `json:"id"` + Recurring *struct { + Interval string `json:"interval"` + } `json:"recurring"` + } `json:"price"` + } `json:"data"` + } `json:"items"` +} + +type apiStripeSubscriptionDeletedEvent struct { + ID string `json:"id"` + Customer string `json:"customer"` +} + +type apiWebPushUpdateSubscriptionRequest struct { + Endpoint string `json:"endpoint"` + Auth string `json:"auth"` + P256dh string `json:"p256dh"` + Topics []string `json:"topics"` +} + +// List of possible Web Push events (see sw.js) +const ( + webPushMessageEvent = "message" + webPushExpiringEvent = "subscription_expiring" +) + +type webPushPayload struct { + Event string `json:"event"` + SubscriptionID string `json:"subscription_id"` + Message *message `json:"message"` +} + +func newWebPushPayload(subscriptionID string, message *message) *webPushPayload { + return &webPushPayload{ + Event: webPushMessageEvent, + SubscriptionID: subscriptionID, + Message: message, + } +} + +type webPushControlMessagePayload struct { + Event string `json:"event"` +} + +func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload { + return &webPushControlMessagePayload{ + Event: webPushExpiringEvent, + } +} + +type webPushSubscription struct { + ID string + Endpoint string + Auth string + P256dh string + UserID string +} + +func (w *webPushSubscription) Context() log.Context { + return map[string]any{ + "web_push_subscription_id": w.ID, + "web_push_subscription_user_id": w.UserID, + "web_push_subscription_endpoint": w.Endpoint, + } +} + +// https://developer.mozilla.org/en-US/docs/Web/Manifest +type webManifestResponse struct { + Name string `json:"name"` + Description string `json:"description"` + ShortName string `json:"short_name"` + Scope string `json:"scope"` + StartURL string `json:"start_url"` + Display string `json:"display"` + BackgroundColor string `json:"background_color"` + ThemeColor string `json:"theme_color"` + Icons []*webManifestIcon `json:"icons"` +} + +type webManifestIcon struct { + SRC string `json:"src"` + Sizes string `json:"sizes"` + Type string `json:"type"` +} diff --git a/server/util.go b/server/util.go index 7c596344..dea13ed5 100644 --- a/server/util.go +++ b/server/util.go @@ -1,18 +1,49 @@ package server import ( + "context" + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "io" + "mime" "net/http" + "net/netip" + "regexp" "strings" ) +var ( + mimeDecoder mime.WordDecoder + priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`) +) + func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { value := strings.ToLower(readParam(r, names...)) if value == "" { return defaultValue } + return toBool(value) +} + +func isBoolValue(value string) bool { + return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false" +} + +func toBool(value string) bool { return value == "1" || value == "yes" || value == "true" } +func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) { + paramStr := readParam(r, names...) + if paramStr != "" { + params = make([]string, 0) + for _, s := range util.SplitNoEmpty(paramStr, ",") { + params = append(params, strings.TrimSpace(s)) + } + } + return params +} + func readParam(r *http.Request, names ...string) string { value := readHeaderParam(r, names...) if value != "" { @@ -23,9 +54,9 @@ func readParam(r *http.Request, names ...string) string { func readHeaderParam(r *http.Request, names ...string) string { for _, name := range names { - value := r.Header.Get(name) + value := strings.TrimSpace(maybeDecodeHeader(name, r.Header.Get(name))) if value != "" { - return strings.TrimSpace(value) + return value } } return "" @@ -40,3 +71,85 @@ func readQueryParam(r *http.Request, names ...string) string { } return "" } + +func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { + remoteAddr := r.RemoteAddr + addrPort, err := netip.ParseAddrPort(remoteAddr) + ip := addrPort.Addr() + if err != nil { + // This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified + ip, err = netip.ParseAddr(remoteAddr) + if err != nil { + ip = netip.IPv4Unspecified() + if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used + logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr) + } + } + } + if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" { + // X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy, + // only the right-most address can be trusted (as this is the one added by our proxy server). + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. + ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",") + realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr))) + if err != nil { + logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip) + // Fall back to regular remote address if X-Forwarded-For is damaged + } else { + ip = realIP + } + } + return ip +} + +func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { + obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty) + if err == util.ErrUnmarshalJSON { + return nil, errHTTPBadRequestJSONInvalid + } else if err == util.ErrTooLargeJSON { + return nil, errHTTPEntityTooLargeJSONBody + } else if err != nil { + return nil, err + } + return obj, nil +} + +func withContext(r *http.Request, ctx map[contextKey]any) *http.Request { + c := r.Context() + for k, v := range ctx { + c = context.WithValue(c, k, v) + } + return r.WithContext(c) +} + +func fromContext[T any](r *http.Request, key contextKey) (T, error) { + t, ok := r.Context().Value(key).(T) + if !ok { + return t, fmt.Errorf("cannot find key %v in request context", key) + } + return t, nil +} + +// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=", +// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader +// to ignore new HTTP "Priority" header. +func maybeDecodeHeader(name, value string) string { + decoded, err := mimeDecoder.DecodeHeader(value) + if err != nil { + return maybeIgnoreSpecialHeader(name, value) + } + return maybeIgnoreSpecialHeader(name, decoded) +} + +// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority) +// +// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy), +// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored. +// Returning an empty string will allow the rest of the logic to continue searching for another header (x-priority, prio, p), +// or in the Query parameters. +func maybeIgnoreSpecialHeader(name, value string) string { + if strings.ToLower(name) == "priority" && priorityHeaderIgnoreRegex.MatchString(strings.TrimSpace(value)) { + return "" + } + return value +} diff --git a/server/util_test.go b/server/util_test.go index 63bc6b40..6555a81b 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -1,8 +1,12 @@ package server import ( + "bytes" + "crypto/rand" + "fmt" "github.com/stretchr/testify/require" "net/http" + "strings" "testing" ) @@ -27,3 +31,60 @@ func TestReadBoolParam(t *testing.T) { require.Equal(t, false, up) require.Equal(t, true, firebase) } + +func TestRenderHTTPRequest_ValidShort(t *testing.T) { + r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader("some message")) + r.Header.Set("Title", "A title") + expected := `POST /mytopic?p=2 HTTP/1.1 +Title: A title + +some message` + require.Equal(t, expected, renderHTTPRequest(r)) +} + +func TestRenderHTTPRequest_ValidLong(t *testing.T) { + body := strings.Repeat("a", 5000) + r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader(body)) + r.Header.Set("Accept", "*/*") + expected := `POST /mytopic?p=2 HTTP/1.1 +Accept: */* + +` + strings.Repeat("a", 4096) + " ... (peeked 4096 bytes)" + require.Equal(t, expected, renderHTTPRequest(r)) +} + +func TestRenderHTTPRequest_InvalidShort(t *testing.T) { + body := []byte{0xc3, 0x28} + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body)) + r.Header.Set("Accept", "*/*") + expected := `GET /mytopic/json?since=all HTTP/1.1 +Accept: */* + +(peeked bytes not UTF-8, 2 bytes, hex: c328)` + require.Equal(t, expected, renderHTTPRequest(r)) +} + +func TestRenderHTTPRequest_InvalidLong(t *testing.T) { + body := make([]byte, 5000) + rand.Read(body) + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body)) + r.Header.Set("Accept", "*/*") + expected := `GET /mytopic/json?since=all HTTP/1.1 +Accept: */* + +(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)` + require.Equal(t, expected, renderHTTPRequest(r)) +} + +func TestMaybeIgnoreSpecialHeader(t *testing.T) { + require.Empty(t, maybeIgnoreSpecialHeader("priority", "u=1")) + require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1")) + require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1, i")) +} + +func TestMaybeDecodeHeaders(t *testing.T) { + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) + r.Header.Set("Priority", "u=1") // Cloudflare priority header + r.Header.Set("X-Priority", "5") // ntfy priority header + require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p")) +} diff --git a/server/visitor.go b/server/visitor.go index 58cc28ab..d1ec1226 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -1,88 +1,298 @@ package server import ( - "errors" - "golang.org/x/time/rate" - "heckel.io/ntfy/util" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/user" + "net/netip" "sync" "time" + + "git.zio.sh/astra/ntfy/v2/util" + "golang.org/x/time/rate" ) const ( + // oneDay is an approximation of a day as a time.Duration + oneDay = 24 * time.Hour + // visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number // has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since // they are replenished faster (typically). - visitorExpungeAfter = 24 * time.Hour + visitorExpungeAfter = oneDay + + // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. + // This number is zero, and changing it may have unintended consequences in the web app, or otherwise + visitorDefaultReservationsLimit = int64(0) + + // visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make. + // This number is zero, because phone numbers have to be verified first. + visitorDefaultCallsLimit = int64(0) ) -var ( - errVisitorLimitReached = errors.New("limit reached") +// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter +// values (token bucket). This is only used to increase the values in server.yml, never decrease them. +// +// Example: Assuming a user.Tier's MessageLimit is 10,000: +// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max) +// - the replenish rate is 2 * 10,000 / 24 hours +const ( + visitorMessageToRequestLimitBurstRate = 0.05 + visitorMessageToRequestLimitBurstMax = 1000 + visitorMessageToRequestLimitReplenishFactor = 2 +) + +// Constants used to convert a tier-user's EmailLimit (see user.Tier) into adequate email limiter +// values (token bucket). Example: Assuming a user.Tier's EmailLimit is 200, the allowed burst is +// 40 (= 200 * 20%), which is <150 (the max). +const ( + visitorEmailLimitBurstRate = 0.2 + visitorEmailLimitBurstMax = 150 ) // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { - config *Config - messageCache *messageCache - ip string - requests *rate.Limiter - emails *rate.Limiter - subscriptions util.Limiter - bandwidth util.Limiter - seen time.Time - mu sync.Mutex + config *Config + messageCache *messageCache + userManager *user.Manager // May be nil + ip netip.Addr // Visitor IP address + user *user.User // Only set if authenticated user, otherwise nil + requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) + messagesLimiter *util.FixedLimiter // Rate limiter for messages + emailsLimiter *util.RateLimiter // Rate limiter for emails + callsLimiter *util.FixedLimiter // Rate limiter for calls + subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) + bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads + accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil + authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil + firebase time.Time // Next allowed Firebase message + seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) + mu sync.RWMutex +} + +type visitorInfo struct { + Limits *visitorLimits + Stats *visitorStats +} + +type visitorLimits struct { + Basis visitorLimitBasis + RequestLimitBurst int + RequestLimitReplenish rate.Limit + MessageLimit int64 + MessageExpiryDuration time.Duration + EmailLimit int64 + EmailLimitBurst int + EmailLimitReplenish rate.Limit + CallLimit int64 + ReservationsLimit int64 + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + AttachmentBandwidthLimit int64 } type visitorStats struct { - AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"` - VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"` - VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"` - VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"` + Messages int64 + MessagesRemaining int64 + Emails int64 + EmailsRemaining int64 + Calls int64 + CallsRemaining int64 + Reservations int64 + ReservationsRemaining int64 + AttachmentTotalSize int64 + AttachmentTotalSizeRemaining int64 } -func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor { - return &visitor{ - config: conf, - messageCache: messageCache, - ip: ip, - requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), - emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), - subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), - bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), - seen: time.Now(), +// visitorLimitBasis describes how the visitor limits were derived, either from a user's +// IP address (default config), or from its tier +type visitorLimitBasis string + +const ( + visitorLimitBasisIP = visitorLimitBasis("ip") + visitorLimitBasisTier = visitorLimitBasis("tier") +) + +func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { + var messages, emails, calls int64 + if user != nil { + messages = user.Stats.Messages + emails = user.Stats.Emails + calls = user.Stats.Calls } -} - -func (v *visitor) IP() string { - return v.ip -} - -func (v *visitor) RequestAllowed() error { - if !v.requests.Allow() { - return errVisitorLimitReached + v := &visitor{ + config: conf, + messageCache: messageCache, + userManager: userManager, // May be nil + ip: ip, + user: user, + firebase: time.Unix(0, 0), + seen: time.Now(), + subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), + requestLimiter: nil, // Set in resetLimiters + messagesLimiter: nil, // Set in resetLimiters, may be nil + emailsLimiter: nil, // Set in resetLimiters + callsLimiter: nil, // Set in resetLimiters, may be nil + bandwidthLimiter: nil, // Set in resetLimiters + accountLimiter: nil, // Set in resetLimiters, may be nil + authLimiter: nil, // Set in resetLimiters, may be nil } - return nil + v.resetLimitersNoLock(messages, emails, calls, false) + return v } -func (v *visitor) EmailAllowed() error { - if !v.emails.Allow() { - return errVisitorLimitReached +func (v *visitor) Context() log.Context { + v.mu.RLock() + defer v.mu.RUnlock() + return v.contextNoLock() +} + +func (v *visitor) contextNoLock() log.Context { + info := v.infoLightNoLock() + fields := log.Context{ + "visitor_id": visitorID(v.ip, v.user), + "visitor_ip": v.ip.String(), + "visitor_seen": util.FormatTime(v.seen), + "visitor_messages": info.Stats.Messages, + "visitor_messages_limit": info.Limits.MessageLimit, + "visitor_messages_remaining": info.Stats.MessagesRemaining, + "visitor_request_limiter_limit": v.requestLimiter.Limit(), + "visitor_request_limiter_tokens": v.requestLimiter.Tokens(), } - return nil + if v.config.SMTPSenderFrom != "" { + fields["visitor_emails"] = info.Stats.Emails + fields["visitor_emails_limit"] = info.Limits.EmailLimit + fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining + } + if v.config.TwilioAccount != "" { + fields["visitor_calls"] = info.Stats.Calls + fields["visitor_calls_limit"] = info.Limits.CallLimit + fields["visitor_calls_remaining"] = info.Stats.CallsRemaining + } + if v.authLimiter != nil { + fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() + fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() + } + if v.user != nil { + fields["user_id"] = v.user.ID + fields["user_name"] = v.user.Name + if v.user.Tier != nil { + for field, value := range v.user.Tier.Context() { + fields[field] = value + } + } + if v.user.Billing.StripeCustomerID != "" { + fields["stripe_customer_id"] = v.user.Billing.StripeCustomerID + } + if v.user.Billing.StripeSubscriptionID != "" { + fields["stripe_subscription_id"] = v.user.Billing.StripeSubscriptionID + } + } + return fields } -func (v *visitor) SubscriptionAllowed() error { +func visitorExtendedInfoContext(info *visitorInfo) log.Context { + return log.Context{ + "visitor_reservations": info.Stats.Reservations, + "visitor_reservations_limit": info.Limits.ReservationsLimit, + "visitor_reservations_remaining": info.Stats.ReservationsRemaining, + "visitor_attachment_total_size": info.Stats.AttachmentTotalSize, + "visitor_attachment_total_size_limit": info.Limits.AttachmentTotalSizeLimit, + "visitor_attachment_total_size_remaining": info.Stats.AttachmentTotalSizeRemaining, + } + +} +func (v *visitor) RequestAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.requestLimiter.Allow() +} + +func (v *visitor) FirebaseAllowed() bool { + v.mu.RLock() + defer v.mu.RUnlock() + return !time.Now().Before(v.firebase) +} + +func (v *visitor) FirebaseTemporarilyDeny() { v.mu.Lock() defer v.mu.Unlock() - if err := v.subscriptions.Allow(1); err != nil { - return errVisitorLimitReached + v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration) +} + +func (v *visitor) MessageAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.messagesLimiter.Allow() +} + +func (v *visitor) EmailAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.emailsLimiter.Allow() +} + +func (v *visitor) CallAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.callsLimiter.Allow() +} + +func (v *visitor) SubscriptionAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.subscriptionLimiter.Allow() +} + +// AuthAllowed returns true if an auth request can be attempted (> 1 token available) +func (v *visitor) AuthAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.authLimiter == nil { + return true } - return nil + return v.authLimiter.Tokens() > 1 +} + +// AuthFailed records an auth failure +func (v *visitor) AuthFailed() { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.authLimiter != nil { + v.authLimiter.Allow() + } +} + +// AccountCreationAllowed returns true if a new account can be created +func (v *visitor) AccountCreationAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) { + return false + } + return true +} + +// AccountCreated decreases the account limiter. This is to be called after an account was created. +func (v *visitor) AccountCreated() { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.accountLimiter != nil { + v.accountLimiter.Allow() + } +} + +func (v *visitor) BandwidthAllowed(bytes int64) bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.bandwidthLimiter.AllowN(bytes) } func (v *visitor) RemoveSubscription() { - v.mu.Lock() - defer v.mu.Unlock() - v.subscriptions.Allow(-1) + v.mu.RLock() + defer v.mu.RUnlock() + v.subscriptionLimiter.AllowN(-1) } func (v *visitor) Keepalive() { @@ -92,28 +302,231 @@ func (v *visitor) Keepalive() { } func (v *visitor) BandwidthLimiter() util.Limiter { - return v.bandwidth + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.bandwidthLimiter } func (v *visitor) Stale() bool { - v.mu.Lock() - defer v.mu.Unlock() + v.mu.RLock() + defer v.mu.RUnlock() return time.Since(v.seen) > visitorExpungeAfter } -func (v *visitor) Stats() (*visitorStats, error) { - attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip) +func (v *visitor) Stats() *user.Stats { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return &user.Stats{ + Messages: v.messagesLimiter.Value(), + Emails: v.emailsLimiter.Value(), + Calls: v.callsLimiter.Value(), + } +} + +func (v *visitor) ResetStats() { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + v.emailsLimiter.Reset() + v.messagesLimiter.Reset() + v.callsLimiter.Reset() +} + +// User returns the visitor user, or nil if there is none +func (v *visitor) User() *user.User { + v.mu.RLock() + defer v.mu.RUnlock() + return v.user // May be nil +} + +// IP returns the visitor IP address +func (v *visitor) IP() netip.Addr { + v.mu.RLock() + defer v.mu.RUnlock() + return v.ip +} + +// Authenticated returns true if a user successfully authenticated +func (v *visitor) Authenticated() bool { + v.mu.RLock() + defer v.mu.RUnlock() + return v.user != nil +} + +// SetUser sets the visitors user to the given value +func (v *visitor) SetUser(u *user.User) { + v.mu.Lock() + defer v.mu.Unlock() + shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver + v.user = u // u may be nil! + if shouldResetLimiters { + var messages, emails, calls int64 + if u != nil { + messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls + } + v.resetLimitersNoLock(messages, emails, calls, true) + } +} + +// MaybeUserID returns the user ID of the visitor (if any). If this is an anonymous visitor, +// an empty string is returned. +func (v *visitor) MaybeUserID() string { + v.mu.RLock() + defer v.mu.RUnlock() + if v.user != nil { + return v.user.ID + } + return "" +} + +func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) { + limits := v.limitsNoLock() + v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) + v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) + v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) + v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) + v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) + if v.user == nil { + v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) + v.authLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAuthFailureLimitReplenish), v.config.VisitorAuthFailureLimitBurst) + } else { + v.accountLimiter = nil // Users cannot create accounts when logged in + v.authLimiter = nil // Users are already logged in, no need to limit requests + } + if enqueueUpdate && v.user != nil { + go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ + Messages: messages, + Emails: emails, + Calls: calls, + }) + } + log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters +} + +func (v *visitor) Limits() *visitorLimits { + v.mu.RLock() + defer v.mu.RUnlock() + return v.limitsNoLock() +} + +func (v *visitor) limitsNoLock() *visitorLimits { + if v.user != nil && v.user.Tier != nil { + return tierBasedVisitorLimits(v.config, v.user.Tier) + } + return configBasedVisitorLimits(v.config) +} + +func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits { + return &visitorLimits{ + Basis: visitorLimitBasisTier, + RequestLimitBurst: util.MinMax(int(float64(tier.MessageLimit)*visitorMessageToRequestLimitBurstRate), conf.VisitorRequestLimitBurst, visitorMessageToRequestLimitBurstMax), + RequestLimitReplenish: util.Max(rate.Every(conf.VisitorRequestLimitReplenish), dailyLimitToRate(tier.MessageLimit*visitorMessageToRequestLimitReplenishFactor)), + MessageLimit: tier.MessageLimit, + MessageExpiryDuration: tier.MessageExpiryDuration, + EmailLimit: tier.EmailLimit, + EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), + EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), + CallLimit: tier.CallLimit, + ReservationsLimit: tier.ReservationLimit, + AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, + AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, + AttachmentExpiryDuration: tier.AttachmentExpiryDuration, + AttachmentBandwidthLimit: tier.AttachmentBandwidthLimit, + } +} + +func configBasedVisitorLimits(conf *Config) *visitorLimits { + messagesLimit := replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish) // Approximation! + if conf.VisitorMessageDailyLimit > 0 { + messagesLimit = int64(conf.VisitorMessageDailyLimit) + } + return &visitorLimits{ + Basis: visitorLimitBasisIP, + RequestLimitBurst: conf.VisitorRequestLimitBurst, + RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish), + MessageLimit: messagesLimit, + MessageExpiryDuration: conf.CacheDuration, + EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! + EmailLimitBurst: conf.VisitorEmailLimitBurst, + EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), + CallLimit: visitorDefaultCallsLimit, + ReservationsLimit: visitorDefaultReservationsLimit, + AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, + AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, + AttachmentExpiryDuration: conf.AttachmentExpiryDuration, + AttachmentBandwidthLimit: conf.VisitorAttachmentDailyBandwidthLimit, + } +} + +func (v *visitor) Info() (*visitorInfo, error) { + v.mu.RLock() + info := v.infoLightNoLock() + v.mu.RUnlock() + + // Attachment stats from database + var attachmentsBytesUsed int64 + var err error + u := v.User() + if u != nil { + attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(u.ID) + } else { + attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.IP().String()) + } if err != nil { return nil, err } - attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed - if attachmentsBytesRemaining < 0 { - attachmentsBytesRemaining = 0 + info.Stats.AttachmentTotalSize = attachmentsBytesUsed + info.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed) + + // Reservation stats from database + var reservations int64 + if v.userManager != nil && u != nil { + reservations, err = v.userManager.ReservationsCount(u.Name) + if err != nil { + return nil, err + } } - return &visitorStats{ - AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit, - VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit, - VisitorAttachmentBytesUsed: attachmentsBytesUsed, - VisitorAttachmentBytesRemaining: attachmentsBytesRemaining, - }, nil + info.Stats.Reservations = reservations + info.Stats.ReservationsRemaining = zeroIfNegative(info.Limits.ReservationsLimit - reservations) + + return info, nil +} + +func (v *visitor) infoLightNoLock() *visitorInfo { + messages := v.messagesLimiter.Value() + emails := v.emailsLimiter.Value() + calls := v.callsLimiter.Value() + limits := v.limitsNoLock() + stats := &visitorStats{ + Messages: messages, + MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), + Emails: emails, + EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), + Calls: calls, + CallsRemaining: zeroIfNegative(limits.CallLimit - calls), + } + return &visitorInfo{ + Limits: limits, + Stats: stats, + } +} +func zeroIfNegative(value int64) int64 { + if value < 0 { + return 0 + } + return value +} + +func replenishDurationToDailyLimit(duration time.Duration) int64 { + return int64(oneDay / duration) +} + +func dailyLimitToRate(limit int64) rate.Limit { + return rate.Limit(limit) * rate.Every(oneDay) +} + +func visitorID(ip netip.Addr, u *user.User) string { + if u != nil && u.Tier != nil { + return fmt.Sprintf("user:%s", u.ID) + } + return fmt.Sprintf("ip:%s", ip.String()) } diff --git a/server/webpush_store.go b/server/webpush_store.go new file mode 100644 index 00000000..3781f250 --- /dev/null +++ b/server/webpush_store.go @@ -0,0 +1,280 @@ +package server + +import ( + "database/sql" + "errors" + "git.zio.sh/astra/ntfy/v2/util" + "net/netip" + "time" + + _ "github.com/mattn/go-sqlite3" // SQLite driver +) + +const ( + subscriptionIDPrefix = "wps_" + subscriptionIDLength = 10 + subscriptionEndpointLimitPerSubscriberIP = 10 +) + +var ( + errWebPushNoRows = errors.New("no rows found") + errWebPushTooManySubscriptions = errors.New("too many subscriptions") + errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") +) + +const ( + createWebPushSubscriptionsTableQuery = ` + BEGIN; + CREATE TABLE IF NOT EXISTS subscription ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, + key_auth TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + user_id TEXT NOT NULL, + subscriber_ip TEXT NOT NULL, + updated_at INT NOT NULL, + warned_at INT NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint); + CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip); + CREATE TABLE IF NOT EXISTS subscription_topic ( + subscription_id TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY (subscription_id, topic), + FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + COMMIT; + ` + builtinStartupQueries = ` + PRAGMA foreign_keys = ON; + ` + + selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?` + selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` + selectWebPushSubscriptionsForTopicQuery = ` + SELECT id, endpoint, key_auth, key_p256dh, user_id + FROM subscription_topic st + JOIN subscription s ON s.id = st.subscription_id + WHERE st.topic = ? + ORDER BY endpoint + ` + selectWebPushSubscriptionsExpiringSoonQuery = ` + SELECT id, endpoint, key_auth, key_p256dh, user_id + FROM subscription + WHERE warned_at = 0 AND updated_at <= ? + ` + insertWebPushSubscriptionQuery = ` + INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (endpoint) + DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at + ` + updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` + deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` + deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` + deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! + + insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` + deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` +) + +// Schema management queries +const ( + currentWebPushSchemaVersion = 1 + insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` +) + +type webPushStore struct { + db *sql.DB +} + +func newWebPushStore(filename, startupQueries string) (*webPushStore, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupWebPushDB(db); err != nil { + return nil, err + } + if err := runWebPushStartupQueries(db, startupQueries); err != nil { + return nil, err + } + return &webPushStore{ + db: db, + }, nil +} + +func setupWebPushDB(db *sql.DB) error { + // If 'schemaVersion' table does not exist, this must be a new database + rows, err := db.Query(selectWebPushSchemaVersionQuery) + if err != nil { + return setupNewWebPushDB(db) + } + return rows.Close() +} + +func setupNewWebPushDB(db *sql.DB) error { + if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil { + return err + } + if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil { + return err + } + return nil +} + +func runWebPushStartupQueries(db *sql.DB, startupQueries string) error { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + if _, err := db.Exec(builtinStartupQueries); err != nil { + return err + } + return nil +} + +// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all +// existing entries for a given endpoint. +func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Read number of subscriptions for subscriber IP address + rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String()) + if err != nil { + return err + } + defer rowsCount.Close() + var subscriptionCount int + if !rowsCount.Next() { + return errWebPushNoRows + } + if err := rowsCount.Scan(&subscriptionCount); err != nil { + return err + } + if err := rowsCount.Close(); err != nil { + return err + } + // Read existing subscription ID for endpoint (or create new ID) + rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint) + if err != nil { + return err + } + defer rows.Close() + var subscriptionID string + if rows.Next() { + if err := rows.Scan(&subscriptionID); err != nil { + return err + } + } else { + if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { + return errWebPushTooManySubscriptions + } + subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) + } + if err := rows.Close(); err != nil { + return err + } + // Insert or update subscription + updatedAt, warnedAt := time.Now().Unix(), 0 + if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + return err + } + // Replace all subscription topics + if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil { + return err + } + for _, topic := range topics { + if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil { + return err + } + } + return tx.Commit() +} + +// SubscriptionsForTopic returns all subscriptions for the given topic +func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) { + rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic) + if err != nil { + return nil, err + } + defer rows.Close() + return c.subscriptionsFromRows(rows) +} + +// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period +func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) { + rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + return c.subscriptionsFromRows(rows) +} + +// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon +func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, subscription := range subscriptions { + if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { + return err + } + } + return tx.Commit() +} + +func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) { + subscriptions := make([]*webPushSubscription, 0) + for rows.Next() { + var id, endpoint, auth, p256dh, userID string + if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { + return nil, err + } + subscriptions = append(subscriptions, &webPushSubscription{ + ID: id, + Endpoint: endpoint, + Auth: auth, + P256dh: p256dh, + UserID: userID, + }) + } + return subscriptions, nil +} + +// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint +func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error { + _, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint) + return err +} + +// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID +func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error { + if userID == "" { + return errWebPushUserIDCannotBeEmpty + } + _, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID) + return err +} + +// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period +func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { + _, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) + return err +} + +// Close closes the underlying database connection +func (c *webPushStore) Close() error { + return c.db.Close() +} diff --git a/server/webpush_store_test.go b/server/webpush_store_test.go new file mode 100644 index 00000000..ab5bc424 --- /dev/null +++ b/server/webpush_store_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/netip" + "path/filepath" + "testing" + "time" +) + +func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + subs, err := webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "u_1234") + + subs2, err := webPush.SubscriptionsForTopic("mytopic") + require.Nil(t, err) + require.Len(t, subs2, 1) + require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) +} + +func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert 10 subscriptions with the same IP address + for i := 0; i < 10; i++ { + endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) + require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + } + + // Another one for the same endpoint should be fine + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different endpoint it should fail + require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different IP address it should be fine again + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) +} + +func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics, and another with one topic + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) + + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) + + subs, err = webPush.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + // Update the first subscription to have only one topic + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + subs, err = webPush.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) + subs, err = webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234")) + subs, err = webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID("")) +} + +func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Mark them as warning sent + require.Nil(t, webPush.MarkExpiryWarningSent(subs)) + + rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0") + require.Nil(t, err) + defer rows.Close() + var endpoint string + require.True(t, rows.Next()) + require.Nil(t, rows.Scan(&endpoint)) + require.Nil(t, err) + require.Equal(t, testWebPushEndpoint, endpoint) + require.False(t, rows.Next()) +} + +func TestWebPushStore_SubscriptionsExpiring(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as soon-to-expire + _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Should not be cleaned up yet + require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // Run expiration + subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) +} + +func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) { + webPush := newTestWebPushStore(t) + defer webPush.Close() + + // Insert subscription with two topics + require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as expired + _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Run expiration + require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // List again, should be 0 + subs, err = webPush.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func newTestWebPushStore(t *testing.T) *webPushStore { + webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "") + require.Nil(t, err) + return webPush +} diff --git a/test/server.go b/test/server.go index 0b9200a6..cabff94f 100644 --- a/test/server.go +++ b/test/server.go @@ -2,7 +2,7 @@ package test import ( "fmt" - "heckel.io/ntfy/server" + "git.zio.sh/astra/ntfy/v2/server" "math/rand" "net/http" "path/filepath" diff --git a/tools/fbsend/main.go b/tools/fbsend/main.go index cd3a06d1..832aeb79 100644 --- a/tools/fbsend/main.go +++ b/tools/fbsend/main.go @@ -2,8 +2,8 @@ package main import ( "context" - firebase "firebase.google.com/go" - "firebase.google.com/go/messaging" + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/messaging" "flag" "fmt" "google.golang.org/api/option" diff --git a/tools/loadgen/main.go b/tools/loadgen/main.go new file mode 100644 index 00000000..4ce201d8 --- /dev/null +++ b/tools/loadgen/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "time" +) + +func main() { + baseURL := "https://staging.ntfy.sh" + if len(os.Args) > 1 { + baseURL = os.Args[1] + } + for i := 0; i < 2000; i++ { + go subscribe(i, baseURL) + } + time.Sleep(5 * time.Second) + for i := 0; i < 2000; i++ { + go func(worker int) { + for { + poll(worker, baseURL) + } + }(i) + } + time.Sleep(time.Hour) +} + +func subscribe(worker int, baseURL string) { + fmt.Printf("[subscribe] worker=%d STARTING\n", worker) + start := time.Now() + topic, ip := fmt.Sprintf("subtopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255) + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s/json", baseURL, topic), nil) + req.Header.Set("X-Forwarded-For", ip) + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("[subscribe] worker=%d time=%d error=%s\n", worker, time.Since(start).Milliseconds(), err.Error()) + return + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + // Do nothing + } + fmt.Printf("[subscribe] worker=%d status=%d time=%d EXITED\n", worker, resp.StatusCode, time.Since(start).Milliseconds()) +} + +func poll(worker int, baseURL string) { + fmt.Printf("[poll] worker=%d STARTING\n", worker) + topic, ip := fmt.Sprintf("polltopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255) + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + + //req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://staging.ntfy.sh/%s/json?poll=1&since=all", topic), nil) + req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/json?poll=1&since=all", baseURL, topic), nil) + req.Header.Set("X-Forwarded-For", ip) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("[poll] worker=%d time=%d status=- error=%s\n", worker, time.Since(start).Milliseconds(), err.Error()) + cancel() + return + } + defer resp.Body.Close() + fmt.Printf("[poll] worker=%d time=%d status=%s\n", worker, time.Since(start).Milliseconds(), resp.Status) +} diff --git a/user/manager.go b/user/manager.go new file mode 100644 index 00000000..0ff211cf --- /dev/null +++ b/user/manager.go @@ -0,0 +1,1676 @@ +// Package user deals with authentication and authorization against topics +package user + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/mattn/go-sqlite3" + "github.com/stripe/stripe-go/v74" + "golang.org/x/crypto/bcrypt" + "net/netip" + "strings" + "sync" + "time" +) + +const ( + tierIDPrefix = "ti_" + tierIDLength = 8 + syncTopicPrefix = "st_" + syncTopicLength = 16 + userIDPrefix = "u_" + userIDLength = 12 + userAuthIntentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match DefaultUserPasswordBcryptCost + userHardDeleteAfterDuration = 7 * 24 * time.Hour + tokenPrefix = "tk_" + tokenLength = 32 + tokenMaxCount = 20 // Only keep this many tokens in the table per user + tag = "user_manager" +) + +// Default constants that may be overridden by configs +const ( + DefaultUserStatsQueueWriterInterval = 33 * time.Second + DefaultUserPasswordBcryptCost = 10 +) + +var ( + errNoTokenProvided = errors.New("no token provided") + errTopicOwnedByOthers = errors.New("topic owned by others") + errNoRows = errors.New("no rows found") +) + +// Manager-related queries +const ( + createTablesQueries = ` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + calls_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_monthly_price_id TEXT, + stripe_yearly_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); + CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); + CREATE TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO user (id, user, pass, role, sync_topic, created) + VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH()) + ON CONFLICT (id) DO NOTHING; + COMMIT; + ` + + builtinStartupQueries = ` + PRAGMA foreign_keys = ON; + ` + + selectUserByIDQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.id = ? + ` + selectUserByNameQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE user = ? + ` + selectUserByTokenQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + JOIN user_token tk on u.id = tk.user_id + LEFT JOIN tier t on t.id = u.tier_id + WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) + ` + selectUserByStripeCustomerIDQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.stripe_customer_id = ? + ` + selectTopicPermsQuery = ` + SELECT read, write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' + ORDER BY u.user DESC + ` + + insertUserQuery = ` + INSERT INTO user (id, user, pass, role, sync_topic, created) + VALUES (?, ?, ?, ?, ?, ?) + ` + selectUsernamesQuery = ` + SELECT user + FROM user + ORDER BY + CASE role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, user + ` + selectUserCountQuery = `SELECT COUNT(*) FROM user` + updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` + updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` + updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` + deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` + deleteUserQuery = `DELETE FROM user WHERE user = ?` + + upsertUserAccessQuery = ` + INSERT INTO user_access (user_id, topic, read, write, owner_user_id) + VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) + ON CONFLICT (user_id, topic) + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id + ` + selectUserAllAccessQuery = ` + SELECT user_id, topic, read, write + FROM user_access + ORDER BY write DESC, read DESC, topic + ` + selectUserAccessQuery = ` + SELECT topic, read, write + FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY write DESC, read DESC, topic + ` + selectUserReservationsQuery = ` + SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write + FROM user_access a_user + LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) + WHERE a_user.user_id = a_user.owner_user_id + AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY a_user.topic + ` + selectUserReservationsCountQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + selectUserReservationsOwnerQuery = ` + SELECT owner_user_id + FROM user_access + WHERE topic = ? + AND user_id = owner_user_id + ` + selectUserHasReservationQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM user WHERE user = ?) + AND topic = ? + ` + selectOtherAccessCountQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE (topic = ? OR ? LIKE topic ESCAPE '\') + AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) + ` + deleteAllAccessQuery = `DELETE FROM user_access` + deleteUserAccessQuery = ` + DELETE FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + OR owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + deleteTopicAccessQuery = ` + DELETE FROM user_access + WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) + AND topic = ? + ` + + selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` + selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?` + selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?` + insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)` + updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` + updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` + deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` + deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` + deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` + deleteExcessTokensQuery = ` + DELETE FROM user_token + WHERE user_id = ? + AND (user_id, token) NOT IN ( + SELECT user_id, token + FROM user_token + WHERE user_id = ? + ORDER BY expires DESC + LIMIT ? + ) + ` + + selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` + insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` + deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` + + insertTierQuery = ` + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + updateTierQuery = ` + UPDATE tier + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? + WHERE code = ? + ` + selectTiersQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + ` + selectTierByCodeQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE code = ? + ` + selectTierByPriceIDQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) + ` + updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` + deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` + deleteTierQuery = `DELETE FROM tier WHERE code = ?` + + updateBillingQuery = ` + UPDATE user + SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? + WHERE user = ? + ` +) + +// Schema management queries +const ( + currentSchemaVersion = 5 + insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` + + // 1 -> 2 (complex migration!) + migrate1To2CreateTablesQueries = ` + ALTER TABLE user RENAME TO user_old; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); + CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); + CREATE TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO user (id, user, pass, role, sync_topic, created) + VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) + ON CONFLICT (id) DO NOTHING; + ` + migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` + migrate1To2InsertUserNoTx = ` + INSERT INTO user (id, user, pass, role, sync_topic, created) + SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ? + ` + migrate1To2InsertFromOldTablesAndDropNoTx = ` + INSERT INTO user_access (user_id, topic, read, write) + SELECT u.id, a.topic, a.read, a.write + FROM user u + JOIN access a ON u.user = a.user; + + DROP TABLE access; + DROP TABLE user_old; + ` + + // 2 -> 3 + migrate2To3UpdateQueries = ` + ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT; + ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id; + ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT; + DROP INDEX IF EXISTS idx_tier_price_id; + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + ` + + // 3 -> 4 + migrate3To4UpdateQueries = ` + ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); + ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + ` + + // 4 -> 5 + migrate4To5UpdateQueries = ` + UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); + ` +) + +var ( + migrations = map[int]func(db *sql.DB) error{ + 1: migrateFrom1, + 2: migrateFrom2, + 3: migrateFrom3, + 4: migrateFrom4, + } +) + +// Manager is an implementation of Manager. It stores users and access control list +// in a SQLite database. +type Manager struct { + db *sql.DB + defaultAccess Permission // Default permission if no ACL matches + statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) + tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) + bcryptCost int // Makes testing easier + mu sync.Mutex +} + +var _ Auther = (*Manager)(nil) + +// NewManager creates a new Manager instance +func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupDB(db); err != nil { + return nil, err + } + if err := runStartupQueries(db, startupQueries); err != nil { + return nil, err + } + manager := &Manager{ + db: db, + defaultAccess: defaultAccess, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + bcryptCost: bcryptCost, + } + go manager.asyncQueueWriter(queueWriterInterval) + return manager, nil +} + +// Authenticate checks username and password and returns a User if correct, and the user has not been +// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or +// the password is correct or incorrect. +func (a *Manager) Authenticate(username, password string) (*User, error) { + if username == Everyone { + return nil, ErrUnauthenticated + } + user, err := a.User(username) + if err != nil { + log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") + bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) + return nil, ErrUnauthenticated + } else if user.Deleted { + log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted") + bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) + return nil, ErrUnauthenticated + } else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { + log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)") + return nil, ErrUnauthenticated + } + return user, nil +} + +// AuthenticateToken checks if the token exists and returns the associated User if it does. +// The method sets the User.Token value to the token that was used for authentication. +func (a *Manager) AuthenticateToken(token string) (*User, error) { + if len(token) != tokenLength { + return nil, ErrUnauthenticated + } + user, err := a.userByToken(token) + if err != nil { + log.Tag(tag).Field("token", token).Err(err).Trace("Authentication of token failed") + return nil, ErrUnauthenticated + } + user.Token = token + return user, nil +} + +// CreateToken generates a random token for the given user and returns it. The token expires +// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the +// given user, if there are too many of them. +func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) { + token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "+@" email addresses + tx, err := a.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + access := time.Now() + if _, err := tx.Exec(insertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix()); err != nil { + return nil, err + } + rows, err := tx.Query(selectTokenCountQuery, userID) + if err != nil { + return nil, err + } + defer rows.Close() + if !rows.Next() { + return nil, errNoRows + } + var tokenCount int + if err := rows.Scan(&tokenCount); err != nil { + return nil, err + } + if tokenCount >= tokenMaxCount { + // This pruning logic is done in two queries for efficiency. The SELECT above is a lookup + // on two indices, whereas the query below is a full table scan. + if _, err := tx.Exec(deleteExcessTokensQuery, userID, userID, tokenMaxCount); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &Token{ + Value: token, + Label: label, + LastAccess: access, + LastOrigin: origin, + Expires: expires, + }, nil +} + +// Tokens returns all existing tokens for the user with the given user ID +func (a *Manager) Tokens(userID string) ([]*Token, error) { + rows, err := a.db.Query(selectTokensQuery, userID) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if err == ErrTokenNotFound { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// Token returns a specific token for a user +func (a *Manager) Token(userID, token string) (*Token, error) { + rows, err := a.db.Query(selectTokenQuery, userID, token) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readToken(rows) +} + +func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { + var token, label, lastOrigin string + var lastAccess, expires int64 + if !rows.Next() { + return nil, ErrTokenNotFound + } + if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + lastOriginIP, err := netip.ParseAddr(lastOrigin) + if err != nil { + lastOriginIP = netip.IPv4Unspecified() + } + return &Token{ + Value: token, + Label: label, + LastAccess: time.Unix(lastAccess, 0), + LastOrigin: lastOriginIP, + Expires: time.Unix(expires, 0), + }, nil +} + +// ChangeToken updates a token's label and/or expiry date +func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) { + if token == "" { + return nil, errNoTokenProvided + } + tx, err := a.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if label != nil { + if _, err := tx.Exec(updateTokenLabelQuery, *label, userID, token); err != nil { + return nil, err + } + } + if expires != nil { + if _, err := tx.Exec(updateTokenExpiryQuery, expires.Unix(), userID, token); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return a.Token(userID, token) +} + +// RemoveToken deletes the token defined in User.Token +func (a *Manager) RemoveToken(userID, token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(deleteTokenQuery, userID, token); err != nil { + return err + } + return nil +} + +// RemoveExpiredTokens deletes all expired tokens from the database +func (a *Manager) RemoveExpiredTokens() error { + if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// PhoneNumbers returns all phone numbers for the user with the given user ID +func (a *Manager) PhoneNumbers(userID string) ([]string, error) { + rows, err := a.db.Query(selectPhoneNumbersQuery, userID) + if err != nil { + return nil, err + } + defer rows.Close() + phoneNumbers := make([]string, 0) + for { + phoneNumber, err := a.readPhoneNumber(rows) + if err == ErrPhoneNumberNotFound { + break + } else if err != nil { + return nil, err + } + phoneNumbers = append(phoneNumbers, phoneNumber) + } + return phoneNumbers, nil +} + +func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { + var phoneNumber string + if !rows.Next() { + return "", ErrPhoneNumberNotFound + } + if err := rows.Scan(&phoneNumber); err != nil { + return "", err + } else if err := rows.Err(); err != nil { + return "", err + } + return phoneNumber, nil +} + +// AddPhoneNumber adds a phone number to the user with the given user ID +func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { + if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { + if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return ErrPhoneNumberExists + } + return err + } + return nil +} + +// RemovePhoneNumber deletes a phone number from the user with the given user ID +func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error { + _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) + return err +} + +// RemoveDeletedUsers deletes all users that have been marked deleted for +func (a *Manager) RemoveDeletedUsers() error { + if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// ChangeSettings persists the user settings +func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { + b, err := json.Marshal(prefs) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserPrefsQuery, string(b), userID); err != nil { + return err + } + return nil +} + +// ResetStats resets all user stats in the user database. This touches all users. +func (a *Manager) ResetStats() error { + a.mu.Lock() // Includes database query to avoid races! + defer a.mu.Unlock() + if _, err := a.db.Exec(updateUserStatsResetAllQuery); err != nil { + return err + } + a.statsQueue = make(map[string]*Stats) + return nil +} + +// EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in +// batches at a regular interval +func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { + a.mu.Lock() + defer a.mu.Unlock() + a.statsQueue[userID] = stats +} + +// EnqueueTokenUpdate adds the token update to a queue which writes out token access times +// in batches at a regular interval +func (a *Manager) EnqueueTokenUpdate(tokenID string, update *TokenUpdate) { + a.mu.Lock() + defer a.mu.Unlock() + a.tokenQueue[tokenID] = update +} + +func (a *Manager) asyncQueueWriter(interval time.Duration) { + ticker := time.NewTicker(interval) + for range ticker.C { + if err := a.writeUserStatsQueue(); err != nil { + log.Tag(tag).Err(err).Warn("Writing user stats queue failed") + } + if err := a.writeTokenUpdateQueue(); err != nil { + log.Tag(tag).Err(err).Warn("Writing token update queue failed") + } + } +} + +func (a *Manager) writeUserStatsQueue() error { + a.mu.Lock() + if len(a.statsQueue) == 0 { + a.mu.Unlock() + log.Tag(tag).Trace("No user stats updates to commit") + return nil + } + statsQueue := a.statsQueue + a.statsQueue = make(map[string]*Stats) + a.mu.Unlock() + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) + for userID, update := range statsQueue { + log. + Tag(tag). + Fields(log.Context{ + "user_id": userID, + "messages_count": update.Messages, + "emails_count": update.Emails, + "calls_count": update.Calls, + }). + Trace("Updating stats for user %s", userID) + if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } + } + return tx.Commit() +} + +func (a *Manager) writeTokenUpdateQueue() error { + a.mu.Lock() + if len(a.tokenQueue) == 0 { + a.mu.Unlock() + log.Tag(tag).Trace("No token updates to commit") + return nil + } + tokenQueue := a.tokenQueue + a.tokenQueue = make(map[string]*TokenUpdate) + a.mu.Unlock() + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) + for tokenID, update := range tokenQueue { + log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) + if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); err != nil { + return err + } + } + return tx.Commit() +} + +// Authorize returns nil if the given user has access to the given topic using the desired +// permission. The user param may be nil to signal an anonymous user. +func (a *Manager) Authorize(user *User, topic string, perm Permission) error { + if user != nil && user.Role == RoleAdmin { + return nil // Admin can do everything + } + username := Everyone + if user != nil { + username = user.Name + } + // Select the read/write permissions for this user/topic combo. The query may return two + // rows (one for everyone, and one for the user), but prioritizes the user. + rows, err := a.db.Query(selectTopicPermsQuery, Everyone, username, topic) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + return a.resolvePerms(a.defaultAccess, perm) + } + var read, write bool + if err := rows.Scan(&read, &write); err != nil { + return err + } else if err := rows.Err(); err != nil { + return err + } + return a.resolvePerms(NewPermission(read, write), perm) +} + +func (a *Manager) resolvePerms(base, perm Permission) error { + if perm == PermissionRead && base.IsRead() { + return nil + } else if perm == PermissionWrite && base.IsWrite() { + return nil + } + return ErrUnauthorized +} + +// AddUser adds a user with the given username, password and role +func (a *Manager) AddUser(username, password string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + if err != nil { + return err + } + userID := util.RandomStringPrefix(userIDPrefix, userIDLength) + syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() + if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { + if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return ErrUserExists + } + return err + } + return nil +} + +// RemoveUser deletes the user with the given username. The function returns nil on success, even +// if the user did not exist in the first place. +func (a *Manager) RemoveUser(username string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + // Rows in user_access, user_token, etc. are deleted via foreign keys + if _, err := a.db.Exec(deleteUserQuery, username); err != nil { + return err + } + return nil +} + +// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents +// successful auth via Authenticate. A background process will delete the user at a later date. +func (a *Manager) MarkUserRemoved(user *User) error { + if !AllowedUsername(user.Name) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(deleteUserAccessQuery, user.Name, user.Name); err != nil { + return err + } + if _, err := tx.Exec(deleteAllTokenQuery, user.ID); err != nil { + return err + } + if _, err := tx.Exec(updateUserDeletedQuery, time.Now().Add(userHardDeleteAfterDuration).Unix(), user.ID); err != nil { + return err + } + return tx.Commit() +} + +// Users returns a list of users. It always also returns the Everyone user ("*"). +func (a *Manager) Users() ([]*User, error) { + rows, err := a.db.Query(selectUsernamesQuery) + if err != nil { + return nil, err + } + defer rows.Close() + usernames := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + rows.Close() + users := make([]*User, 0) + for _, username := range usernames { + user, err := a.User(username) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +// UsersCount returns the number of users in the databsae +func (a *Manager) UsersCount() (int64, error) { + rows, err := a.db.Query(selectUserCountQuery) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// User returns the user with the given username if it exists, or ErrUserNotFound otherwise. +// You may also pass Everyone to retrieve the anonymous user and its Grant list. +func (a *Manager) User(username string) (*User, error) { + rows, err := a.db.Query(selectUserByNameQuery, username) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise +func (a *Manager) UserByID(id string) (*User, error) { + rows, err := a.db.Query(selectUserByIDQuery, id) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise. +func (a *Manager) UserByStripeCustomer(stripeCustomerID string) (*User, error) { + rows, err := a.db.Query(selectUserByStripeCustomerIDQuery, stripeCustomerID) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +func (a *Manager) userByToken(token string) (*User, error) { + rows, err := a.db.Query(selectUserByTokenQuery, token, time.Now().Unix()) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +func (a *Manager) readUser(rows *sql.Rows) (*User, error) { + defer rows.Close() + var id, username, hash, role, prefs, syncTopic string + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString + var messages, emails, calls int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + if !rows.Next() { + return nil, ErrUserNotFound + } + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + user := &User{ + ID: id, + Name: username, + Hash: hash, + Role: Role(role), + Prefs: &Prefs{}, + SyncTopic: syncTopic, + Stats: &Stats{ + Messages: messages, + Emails: emails, + Calls: calls, + }, + Billing: &Billing{ + StripeCustomerID: stripeCustomerID.String, // May be empty + StripeSubscriptionID: stripeSubscriptionID.String, // May be empty + StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty + StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty + StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero + StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero + }, + Deleted: deleted.Valid, + } + if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { + return nil, err + } + if tierCode.Valid { + // See readTier() when this is changed! + user.Tier = &Tier{ + ID: tierID.String, + Code: tierCode.String, + Name: tierName.String, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + } + } + return user, nil +} + +// AllGrants returns all user-specific access control entries, mapped to their respective user IDs +func (a *Manager) AllGrants() (map[string][]Grant, error) { + rows, err := a.db.Query(selectUserAllAccessQuery) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make(map[string][]Grant, 0) + for rows.Next() { + var userID, topic string + var read, write bool + if err := rows.Scan(&userID, &topic, &read, &write); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + if _, ok := grants[userID]; !ok { + grants[userID] = make([]Grant, 0) + } + grants[userID] = append(grants[userID], Grant{ + TopicPattern: fromSQLWildcard(topic), + Allow: NewPermission(read, write), + }) + } + return grants, nil +} + +// Grants returns all user-specific access control entries +func (a *Manager) Grants(username string) ([]Grant, error) { + rows, err := a.db.Query(selectUserAccessQuery, username) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make([]Grant, 0) + for rows.Next() { + var topic string + var read, write bool + if err := rows.Scan(&topic, &read, &write); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + grants = append(grants, Grant{ + TopicPattern: fromSQLWildcard(topic), + Allow: NewPermission(read, write), + }) + } + return grants, nil +} + +// Reservations returns all user-owned topics, and the associated everyone-access +func (a *Manager) Reservations(username string) ([]Reservation, error) { + rows, err := a.db.Query(selectUserReservationsQuery, Everyone, username) + if err != nil { + return nil, err + } + defer rows.Close() + reservations := make([]Reservation, 0) + for rows.Next() { + var topic string + var ownerRead, ownerWrite bool + var everyoneRead, everyoneWrite sql.NullBool + if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + reservations = append(reservations, Reservation{ + Topic: unescapeUnderscore(topic), + Owner: NewPermission(ownerRead, ownerWrite), + Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), // false if null + }) + } + return reservations, nil +} + +// HasReservation returns true if the given topic access is owned by the user +func (a *Manager) HasReservation(username, topic string) (bool, error) { + rows, err := a.db.Query(selectUserHasReservationQuery, username, escapeUnderscore(topic)) + if err != nil { + return false, err + } + defer rows.Close() + if !rows.Next() { + return false, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +// ReservationsCount returns the number of reservations owned by this user +func (a *Manager) ReservationsCount(username string) (int64, error) { + rows, err := a.db.Query(selectUserReservationsCountQuery, username) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// ReservationOwner returns user ID of the user that owns this topic, or an +// empty string if it's not owned by anyone +func (a *Manager) ReservationOwner(topic string) (string, error) { + rows, err := a.db.Query(selectUserReservationsOwnerQuery, escapeUnderscore(topic)) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", nil + } + var ownerUserID string + if err := rows.Scan(&ownerUserID); err != nil { + return "", err + } + return ownerUserID, nil +} + +// ChangePassword changes a user's password +func (a *Manager) ChangePassword(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + if err != nil { + return err + } + if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { + return err + } + return nil +} + +// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, +// all existing access control entries (Grant) are removed, since they are no longer needed. +func (a *Manager) ChangeRole(username string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { + return err + } + if role == RoleAdmin { + if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil { + return err + } + } + return nil +} + +// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, +// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. +func (a *Manager) ChangeTier(username, tier string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + t, err := a.Tier(tier) + if err != nil { + return err + } else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil { + return err + } + if _, err := a.db.Exec(updateUserTierQuery, tier, username); err != nil { + return err + } + return nil +} + +// ResetTier removes the tier from the given user +func (a *Manager) ResetTier(username string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if err := a.checkReservationsLimit(username, 0); err != nil { + return err + } + _, err := a.db.Exec(deleteUserTierQuery, username) + return err +} + +func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { + u, err := a.User(username) + if err != nil { + return err + } + if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit { + reservations, err := a.Reservations(username) + if err != nil { + return err + } else if int64(len(reservations)) > reservationsLimit { + return ErrTooManyReservations + } + } + return nil +} + +// AllowReservation tests if a user may create an access control entry for the given topic. +// If there are any ACL entries that are not owned by the user, an error is returned. +func (a *Manager) AllowReservation(username string, topic string) error { + if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { + return ErrInvalidArgument + } + rows, err := a.db.Query(selectOtherAccessCountQuery, escapeUnderscore(topic), escapeUnderscore(topic), username) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + return errNoRows + } + var otherCount int + if err := rows.Scan(&otherCount); err != nil { + return err + } + if otherCount > 0 { + return errTopicOwnedByOthers + } + return nil +} + +// AllowAccess adds or updates an entry in th access control list for a specific user. It controls +// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry +// owner may either be a user (username), or the system (empty). +func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + owner := "" + if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil { + return err + } + return nil +} + +// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is +// empty) for an entire user. The parameter topicPattern may include wildcards (*). +func (a *Manager) ResetAccess(username string, topicPattern string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { + return ErrInvalidArgument + } + if username == "" && topicPattern == "" { + _, err := a.db.Exec(deleteAllAccessQuery, username) + return err + } else if topicPattern == "" { + _, err := a.db.Exec(deleteUserAccessQuery, username, username) + return err + } + _, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) + return err +} + +// AddReservation creates two access control entries for the given topic: one with full read/write access for the +// given user, and one for Everyone with the permission passed as everyone. The user also owns the entries, and +// can modify or delete them. +func (a *Manager) AddReservation(username string, topic string, everyone Permission) error { + if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil { + return err + } + if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil { + return err + } + return tx.Commit() +} + +// RemoveReservations deletes the access control entries associated with the given username/topic, as +// well as all entries with Everyone/topic. This is the counterpart for AddReservation. +func (a *Manager) RemoveReservations(username string, topics ...string) error { + if !AllowedUsername(username) || username == Everyone || len(topics) == 0 { + return ErrInvalidArgument + } + for _, topic := range topics { + if !AllowedTopic(topic) { + return ErrInvalidArgument + } + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, topic := range topics { + if _, err := tx.Exec(deleteTopicAccessQuery, username, username, escapeUnderscore(topic)); err != nil { + return err + } + if _, err := tx.Exec(deleteTopicAccessQuery, Everyone, Everyone, escapeUnderscore(topic)); err != nil { + return err + } + } + return tx.Commit() +} + +// DefaultAccess returns the default read/write access if no access control entry matches +func (a *Manager) DefaultAccess() Permission { + return a.defaultAccess +} + +// AddTier creates a new tier in the database +func (a *Manager) AddTier(tier *Tier) error { + if tier.ID == "" { + tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) + } + if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + return err + } + return nil +} + +// UpdateTier updates a tier's properties in the database +func (a *Manager) UpdateTier(tier *Tier) error { + if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + return err + } + return nil +} + +// RemoveTier deletes the tier with the given code +func (a *Manager) RemoveTier(code string) error { + if !AllowedTier(code) { + return ErrInvalidArgument + } + // This fails if any user has this tier + if _, err := a.db.Exec(deleteTierQuery, code); err != nil { + return err + } + return nil +} + +// ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information +func (a *Manager) ChangeBilling(username string, billing *Billing) error { + if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { + return err + } + return nil +} + +// Tiers returns a list of all Tier structs +func (a *Manager) Tiers() ([]*Tier, error) { + rows, err := a.db.Query(selectTiersQuery) + if err != nil { + return nil, err + } + defer rows.Close() + tiers := make([]*Tier, 0) + for { + tier, err := a.readTier(rows) + if err == ErrTierNotFound { + break + } else if err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + return tiers, nil +} + +// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist +func (a *Manager) Tier(code string) (*Tier, error) { + rows, err := a.db.Query(selectTierByCodeQuery, code) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readTier(rows) +} + +// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist +func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { + rows, err := a.db.Query(selectTierByPriceIDQuery, priceID, priceID) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readTier(rows) +} + +func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { + var id, code, name string + var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + if !rows.Next() { + return nil, ErrTierNotFound + } + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + // When changed, note readUser() as well + return &Tier{ + ID: id, + Code: code, + Name: name, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + }, nil +} + +// Close closes the underlying database +func (a *Manager) Close() error { + return a.db.Close() +} + +// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, +// and escapes '_', assuming '\' as escape character. +func toSQLWildcard(s string) string { + return escapeUnderscore(strings.ReplaceAll(s, "*", "%")) +} + +// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*', +// and removes the '\_' escape character. +func fromSQLWildcard(s string) string { + return strings.ReplaceAll(unescapeUnderscore(s), "%", "*") +} + +func escapeUnderscore(s string) string { + return strings.ReplaceAll(s, "_", "\\_") +} + +func unescapeUnderscore(s string) string { + return strings.ReplaceAll(s, "\\_", "_") +} + +func runStartupQueries(db *sql.DB, startupQueries string) error { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + if _, err := db.Exec(builtinStartupQueries); err != nil { + return err + } + return nil +} + +func setupDB(db *sql.DB) error { + // If 'schemaVersion' table does not exist, this must be a new database + rowsSV, err := db.Query(selectSchemaVersionQuery) + if err != nil { + return setupNewDB(db) + } + defer rowsSV.Close() + + // If 'schemaVersion' table exists, read version and potentially upgrade + schemaVersion := 0 + if !rowsSV.Next() { + return errors.New("cannot determine schema version: database file may be corrupt") + } + if err := rowsSV.Scan(&schemaVersion); err != nil { + return err + } + rowsSV.Close() + + // Do migrations + if schemaVersion == currentSchemaVersion { + return nil + } else if schemaVersion > currentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) + } + for i := schemaVersion; i < currentSchemaVersion; i++ { + fn, ok := migrations[i] + if !ok { + return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1) + } else if err := fn(db); err != nil { + return err + } + } + return nil +} + +func setupNewDB(db *sql.DB) error { + if _, err := db.Exec(createTablesQueries); err != nil { + return err + } + if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { + return err + } + return nil +} + +func migrateFrom1(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 1 to 2") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Rename user -> user_old, and create new tables + if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil { + return err + } + // Insert users from user_old into new user table, with ID and sync_topic + rows, err := tx.Query(migrate1To2SelectAllOldUsernamesNoTx) + if err != nil { + return err + } + defer rows.Close() + usernames := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return err + } + usernames = append(usernames, username) + } + if err := rows.Close(); err != nil { + return err + } + for _, username := range usernames { + userID := util.RandomStringPrefix(userIDPrefix, userIDLength) + syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) + if _, err := tx.Exec(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { + return err + } + } + // Migrate old "access" table to "user_access" and drop "access" and "user_old" + if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 2); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func migrateFrom2(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 2 to 3") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 3); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom3(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 3 to 4") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom4(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 4 to 5") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 5); err != nil { + return err + } + return tx.Commit() +} + +func nullString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +func nullInt64(v int64) sql.NullInt64 { + if v == 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: v, Valid: true} +} diff --git a/user/manager_test.go b/user/manager_test.go new file mode 100644 index 00000000..bebb0a07 --- /dev/null +++ b/user/manager_test.go @@ -0,0 +1,1282 @@ +package user + +import ( + "database/sql" + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v74" + "golang.org/x/crypto/bcrypt" + "net/netip" + "path/filepath" + "strings" + "testing" + "time" +) + +const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources + +func TestManager_FullScenario_Default_DenyAll(t *testing.T) { + a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! + require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) + require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // Everyone can write to /up* + + phil, err := a.Authenticate("phil", "phil") + require.Nil(t, err) + require.Equal(t, "phil", phil.Name) + require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) + require.Equal(t, RoleAdmin, phil.Role) + + philGrants, err := a.Grants("phil") + require.Nil(t, err) + require.Equal(t, []Grant{}, philGrants) + + ben, err := a.Authenticate("ben", "ben") + require.Nil(t, err) + require.Equal(t, "ben", ben.Name) + require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) + require.Equal(t, RoleUser, ben.Role) + + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, []Grant{ + {"mytopic", PermissionReadWrite}, + {"writeme", PermissionWrite}, + {"readme", PermissionRead}, + {"everyonewrite", PermissionDenyAll}, + }, benGrants) + + notben, err := a.Authenticate("ben", "this is wrong") + require.Nil(t, notben) + require.Equal(t, ErrUnauthenticated, err) + + // Admin can do everything + require.Nil(t, a.Authorize(phil, "sometopic", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(phil, "readme", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "announcements", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "everyonewrite", PermissionWrite)) + + // User cannot do everything + require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "writeme", PermissionRead)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) + + // Everyone else can do barely anything + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "announcements", PermissionWrite)) + require.Nil(t, a.Authorize(nil, "announcements", PermissionRead)) + require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionRead)) + require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionWrite)) + require.Nil(t, a.Authorize(nil, "up1234", PermissionWrite)) // Wildcard permission + require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite)) +} + +func TestManager_AddUser_Invalid(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin)) + require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role")) +} + +func TestManager_AddUser_Timing(t *testing.T) { + a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + start := time.Now().UnixMilli() + require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) + require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) +} + +func TestManager_AddUser_And_Query(t *testing.T) { + a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) + require.Nil(t, a.ChangeBilling("user", &Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + StripeSubscriptionStatus: stripe.SubscriptionStatusActive, + StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, + StripeSubscriptionPaidUntil: time.Now().Add(time.Hour), + StripeSubscriptionCancelAt: time.Unix(0, 0), + })) + + u, err := a.User("user") + require.Nil(t, err) + require.Equal(t, "user", u.Name) + + u2, err := a.UserByID(u.ID) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + + u3, err := a.UserByStripeCustomer("acct_123") + require.Nil(t, err) + require.Equal(t, u.ID, u3.ID) +} + +func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + // Create user, add reservations and token + require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) + require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead)) + + u, err := a.User("user") + require.Nil(t, err) + require.False(t, u.Deleted) + + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + + u, err = a.Authenticate("user", "pass") + require.Nil(t, err) + + _, err = a.AuthenticateToken(token.Value) + require.Nil(t, err) + + reservations, err := a.Reservations("user") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + + // Mark deleted: cannot auth anymore, and all reservations are gone + require.Nil(t, a.MarkUserRemoved(u)) + + _, err = a.Authenticate("user", "pass") + require.Equal(t, ErrUnauthenticated, err) + + _, err = a.AuthenticateToken(token.Value) + require.Equal(t, ErrUnauthenticated, err) + + reservations, err = a.Reservations("user") + require.Nil(t, err) + require.Equal(t, 0, len(reservations)) + + // Make sure user is still there + u, err = a.User("user") + require.Nil(t, err) + require.True(t, u.Deleted) + + _, err = a.db.Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) + require.Nil(t, err) + require.Nil(t, a.RemoveDeletedUsers()) + + _, err = a.User("user") + require.Equal(t, ErrUserNotFound, err) +} + +func TestManager_CreateToken_Only_Lower(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + // Create user, add reservations and token + require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) + u, err := a.User("user") + require.Nil(t, err) + + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.Equal(t, token.Value, strings.ToLower(token.Value)) +} + +func TestManager_UserManagement(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! + require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) + + // Query user details + phil, err := a.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", phil.Name) + require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) // Min cost for testing + require.Equal(t, RoleAdmin, phil.Role) + + philGrants, err := a.Grants("phil") + require.Nil(t, err) + require.Equal(t, []Grant{}, philGrants) + + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, "ben", ben.Name) + require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) // Min cost for testing + require.Equal(t, RoleUser, ben.Role) + + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, []Grant{ + {"mytopic", PermissionReadWrite}, + {"writeme", PermissionWrite}, + {"readme", PermissionRead}, + {"everyonewrite", PermissionDenyAll}, + }, benGrants) + + everyone, err := a.User(Everyone) + require.Nil(t, err) + require.Equal(t, "*", everyone.Name) + require.Equal(t, "", everyone.Hash) + require.Equal(t, RoleAnonymous, everyone.Role) + + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, []Grant{ + {"everyonewrite", PermissionReadWrite}, + {"announcements", PermissionRead}, + }, everyoneGrants) + + // Ben: Before revoking + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) // Overwrite! + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + + // Revoke access for "ben" to "mytopic", then check again + require.Nil(t, a.ResetAccess("ben", "mytopic")) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionWrite)) // Revoked + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionRead)) // Revoked + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) // Unchanged + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) // Unchanged + + // Revoke rest of the access + require.Nil(t, a.ResetAccess("ben", "")) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionRead)) // Revoked + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "wrtiteme", PermissionWrite)) // Revoked + + // User list + users, err := a.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, "*", users[2].Name) + + // Remove user + require.Nil(t, a.RemoveUser("ben")) + _, err = a.User("ben") + require.Equal(t, ErrUserNotFound, err) + + users, err = a.Users() + require.Nil(t, err) + require.Equal(t, 2, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "*", users[1].Name) +} + +func TestManager_ChangePassword(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) + + _, err := a.Authenticate("phil", "phil") + require.Nil(t, err) + + require.Nil(t, a.ChangePassword("phil", "newpass")) + _, err = a.Authenticate("phil", "phil") + require.Equal(t, ErrUnauthenticated, err) + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) +} + +func TestManager_ChangeRole(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleUser, ben.Role) + + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 2, len(benGrants)) + + require.Nil(t, a.ChangeRole("ben", RoleAdmin)) + + ben, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleAdmin, ben.Role) + + benGrants, err = a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 0, len(benGrants)) +} + +func TestManager_Reservations(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll)) + require.Nil(t, a.AddReservation("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) + + reservations, err := a.Reservations("ben") + require.Nil(t, err) + require.Equal(t, 2, len(reservations)) + require.Equal(t, Reservation{ + Topic: "readme", + Owner: PermissionReadWrite, + Everyone: PermissionRead, + }, reservations[0]) + require.Equal(t, Reservation{ + Topic: "ztopic_", + Owner: PermissionReadWrite, + Everyone: PermissionDenyAll, + }, reservations[1]) + + b, err := a.HasReservation("ben", "readme") + require.Nil(t, err) + require.True(t, b) + + b, err = a.HasReservation("ben", "ztopic_") + require.Nil(t, err) + require.True(t, b) + + b, err = a.HasReservation("ben", "ztopicX") // _ != X (used to be a SQL wildcard issue) + require.Nil(t, err) + require.False(t, b) + + b, err = a.HasReservation("notben", "readme") + require.Nil(t, err) + require.False(t, b) + + b, err = a.HasReservation("ben", "something-else") + require.Nil(t, err) + require.False(t, b) + + count, err := a.ReservationsCount("ben") + require.Nil(t, err) + require.Equal(t, int64(2), count) + + count, err = a.ReservationsCount("phil") + require.Nil(t, err) + require.Equal(t, int64(0), count) + + err = a.AllowReservation("phil", "readme") + require.Equal(t, errTopicOwnedByOthers, err) + + err = a.AllowReservation("phil", "ztopic_") + require.Equal(t, errTopicOwnedByOthers, err) + + err = a.AllowReservation("phil", "ztopicX") + require.Nil(t, err) + + err = a.AllowReservation("phil", "not-reserved") + require.Nil(t, err) + + // Now remove them again + require.Nil(t, a.RemoveReservations("ben", "ztopic_", "readme")) + + count, err = a.ReservationsCount("ben") + require.Nil(t, err) + require.Equal(t, int64(0), count) +} + +func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "ntfy Pro", + StripeMonthlyPriceID: "price123", + MessageLimit: 5_000, + MessageExpiryDuration: 3 * 24 * time.Hour, + EmailLimit: 50, + ReservationLimit: 5, + AttachmentFileSizeLimit: 52428800, + AttachmentTotalSizeLimit: 524288000, + AttachmentExpiryDuration: 24 * time.Hour, + })) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.ChangeTier("ben", "pro")) + require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll)) + + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleUser, ben.Role) + require.Equal(t, "pro", ben.Tier.Code) + require.Equal(t, int64(5000), ben.Tier.MessageLimit) + require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration) + require.Equal(t, int64(50), ben.Tier.EmailLimit) + require.Equal(t, int64(5), ben.Tier.ReservationLimit) + require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit) + require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit) + require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration) + + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 1, len(benGrants)) + require.Equal(t, PermissionReadWrite, benGrants[0].Allow) + + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 1, len(everyoneGrants)) + require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow) + + benReservations, err := a.Reservations("ben") + require.Nil(t, err) + require.Equal(t, 1, len(benReservations)) + require.Equal(t, "mytopic", benReservations[0].Topic) + require.Equal(t, PermissionReadWrite, benReservations[0].Owner) + require.Equal(t, PermissionDenyAll, benReservations[0].Everyone) + + // Switch to admin, this should remove all grants and owned ACL entries + require.Nil(t, a.ChangeRole("ben", RoleAdmin)) + + benGrants, err = a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 0, len(benGrants)) + + everyoneGrants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 0, len(everyoneGrants)) +} + +func TestManager_Token_Valid(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + u, err := a.User("ben") + require.Nil(t, err) + + // Create token for user + token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + require.Equal(t, "some label", token.Label) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix()) + + u2, err := a.AuthenticateToken(token.Value) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + require.Equal(t, token.Value, u2.Token) + + token2, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, token.Value, token2.Value) + require.Equal(t, "some label", token2.Label) + + tokens, err := a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(tokens)) + require.Equal(t, "some label", tokens[0].Label) + + tokens, err = a.Tokens("u_notauser") + require.Nil(t, err) + require.Equal(t, 0, len(tokens)) + + // Remove token and auth again + require.Nil(t, a.RemoveToken(u2.ID, u2.Token)) + u3, err := a.AuthenticateToken(token.Value) + require.Equal(t, ErrUnauthenticated, err) + require.Nil(t, u3) + + tokens, err = a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, 0, len(tokens)) +} + +func TestManager_Token_Invalid(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length + require.Nil(t, u) + require.Equal(t, ErrUnauthenticated, err) + + u, err = a.AuthenticateToken("not long enough anyway") + require.Nil(t, u) + require.Equal(t, ErrUnauthenticated, err) +} + +func TestManager_Token_NotFound(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + _, err := a.Token("u_bla", "notfound") + require.Equal(t, ErrTokenNotFound, err) +} + +func TestManager_Token_Expire(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + u, err := a.User("ben") + require.Nil(t, err) + + // Create tokens for user + token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token1.Value) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) + + token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token2.Value) + require.NotEqual(t, token1.Value, token2.Value) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token2.Expires.Unix()) + + // See that tokens work + _, err = a.AuthenticateToken(token1.Value) + require.Nil(t, err) + + _, err = a.AuthenticateToken(token2.Value) + require.Nil(t, err) + + // Modify token expiration in database + _, err = a.db.Exec("UPDATE user_token SET expires = 1 WHERE token = ?", token1.Value) + require.Nil(t, err) + + // Now token1 shouldn't work anymore + _, err = a.AuthenticateToken(token1.Value) + require.Equal(t, ErrUnauthenticated, err) + + result, err := a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value) + require.Nil(t, err) + require.True(t, result.Next()) // Still a matching row + require.Nil(t, result.Close()) + + // Expire tokens and check database rows + require.Nil(t, a.RemoveExpiredTokens()) + + result, err = a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value) + require.Nil(t, err) + require.False(t, result.Next()) // No matching row! + require.Nil(t, result.Close()) +} + +func TestManager_Token_Extend(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + // Try to extend token for user without token + u, err := a.User("ben") + require.Nil(t, err) + + _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour))) + require.Equal(t, errNoTokenProvided, err) + + // Create token for user + token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + + userWithToken, err := a.AuthenticateToken(token.Value) + require.Nil(t, err) + + extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour))) + require.Nil(t, err) + require.Equal(t, token.Value, extendedToken.Value) + require.Equal(t, "changed label", extendedToken.Label) + require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix()) + require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix()) +} + +func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { + // Tests that tokens are automatically deleted when the maximum number of tokens is reached + + a := newTestManager(t, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + + ben, err := a.User("ben") + require.Nil(t, err) + + phil, err := a.User("phil") + require.Nil(t, err) + + // Create 2 tokens for phil + philTokens := make([]string, 0) + token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + philTokens = append(philTokens, token.Value) + + token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + philTokens = append(philTokens, token.Value) + + // Create 22 tokens for ben (only 20 allowed!) + baseTime := time.Now().Add(24 * time.Hour) + benTokens := make([]string, 0) + for i := 0; i < 22; i++ { // + token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + benTokens = append(benTokens, token.Value) + + // Manually modify expiry date to avoid sorting issues (this is a hack) + _, err = a.db.Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value) + require.Nil(t, err) + } + + // Ben: The first 2 tokens should have been wiped and should not work anymore! + _, err = a.AuthenticateToken(benTokens[0]) + require.Equal(t, ErrUnauthenticated, err) + + _, err = a.AuthenticateToken(benTokens[1]) + require.Equal(t, ErrUnauthenticated, err) + + // Ben: The other tokens should still work + for i := 2; i < 22; i++ { + userWithToken, err := a.AuthenticateToken(benTokens[i]) + require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i]) + require.Equal(t, "ben", userWithToken.Name) + require.Equal(t, benTokens[i], userWithToken.Token) + } + + // Phil: All tokens should still work + for i := 0; i < 2; i++ { + userWithToken, err := a.AuthenticateToken(philTokens[i]) + require.Nil(t, err, "token[%d]=%s failed", i, philTokens[i]) + require.Equal(t, "phil", userWithToken.Name) + require.Equal(t, philTokens[i], userWithToken.Token) + } + + var benCount int + rows, err := a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, ben.ID) + require.Nil(t, err) + require.True(t, rows.Next()) + require.Nil(t, rows.Scan(&benCount)) + require.Equal(t, 20, benCount) + + var philCount int + rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID) + require.Nil(t, err) + require.True(t, rows.Next()) + require.Nil(t, rows.Scan(&philCount)) + require.Equal(t, 2, philCount) +} + +func TestManager_EnqueueStats_ResetStats(t *testing.T) { + a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + require.Nil(t, err) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + // Baseline: No messages or emails + u, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + a.EnqueueUserStats(u.ID, &Stats{ + Messages: 11, + Emails: 2, + }) + + // Still no change, because it's queued asynchronously + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + + // After 2 seconds they should be persisted + time.Sleep(2 * time.Second) + + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(11), u.Stats.Messages) + require.Equal(t, int64(2), u.Stats.Emails) + + // Now reset stats (enqueued stats will be thrown out) + a.EnqueueUserStats(u.ID, &Stats{ + Messages: 99, + Emails: 23, + }) + require.Nil(t, a.ResetStats()) + + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) +} + +func TestManager_EnqueueTokenUpdate(t *testing.T) { + a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond) + require.Nil(t, err) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + // Create user and token + u, err := a.User("ben") + require.Nil(t, err) + + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) + require.Nil(t, err) + + // Queue token update + a.EnqueueTokenUpdate(token.Value, &TokenUpdate{ + LastAccess: time.Unix(111, 0).UTC(), + LastOrigin: netip.MustParseAddr("1.2.3.3"), + }) + + // Token has not changed yet. + token2, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix()) + require.Equal(t, token.LastOrigin, token2.LastOrigin) + + // After a second or so they should be persisted + time.Sleep(time.Second) + + token3, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix()) + require.Equal(t, netip.MustParseAddr("1.2.3.3"), token3.LastOrigin) +} + +func TestManager_ChangeSettings(t *testing.T) { + a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + require.Nil(t, err) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + + // No settings + u, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, u.Prefs.Subscriptions) + require.Nil(t, u.Prefs.Notification) + require.Nil(t, u.Prefs.Language) + + // Save with new settings + prefs := &Prefs{ + Language: util.String("de"), + Notification: &NotificationPrefs{ + Sound: util.String("ding"), + MinPriority: util.Int(2), + }, + Subscriptions: []*Subscription{ + { + BaseURL: "https://ntfy.sh", + Topic: "mytopic", + DisplayName: util.String("My Topic"), + }, + }, + } + require.Nil(t, a.ChangeSettings(u.ID, prefs)) + + // Read again + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, util.String("de"), u.Prefs.Language) + require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound) + require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority) + require.Nil(t, u.Prefs.Notification.DeleteAfter) + require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL) + require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic) + require.Equal(t, util.String("My Topic"), u.Prefs.Subscriptions[0].DisplayName) +} + +func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + // Create tier and user + require.Nil(t, a.AddTier(&Tier{ + Code: "supporter", + Name: "Supporter", + MessageLimit: 1, + MessageExpiryDuration: time.Second, + EmailLimit: 1, + ReservationLimit: 1, + AttachmentFileSizeLimit: 1, + AttachmentTotalSizeLimit: 1, + AttachmentExpiryDuration: time.Second, + AttachmentBandwidthLimit: 1, + StripeMonthlyPriceID: "price_1", + })) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "Pro", + MessageLimit: 123, + MessageExpiryDuration: 86400 * time.Second, + EmailLimit: 32, + ReservationLimit: 2, + AttachmentFileSizeLimit: 1231231, + AttachmentTotalSizeLimit: 123123, + AttachmentExpiryDuration: 10800 * time.Second, + AttachmentBandwidthLimit: 21474836480, + StripeMonthlyPriceID: "price_2", + })) + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.ChangeTier("phil", "pro")) + + ti, err := a.Tier("pro") + require.Nil(t, err) + + u, err := a.User("phil") + require.Nil(t, err) + + // These are populated by different SQL queries + require.Equal(t, ti, u.Tier) + + // Fields + require.True(t, strings.HasPrefix(ti.ID, "ti_")) + require.Equal(t, "pro", ti.Code) + require.Equal(t, "Pro", ti.Name) + require.Equal(t, int64(123), ti.MessageLimit) + require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(32), ti.EmailLimit) + require.Equal(t, int64(2), ti.ReservationLimit) + require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) + require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + + // Update tier + ti.EmailLimit = 999999 + require.Nil(t, a.UpdateTier(ti)) + + // List tiers + tiers, err := a.Tiers() + require.Nil(t, err) + require.Equal(t, 2, len(tiers)) + + ti = tiers[0] + require.Equal(t, "supporter", ti.Code) + require.Equal(t, "Supporter", ti.Name) + require.Equal(t, int64(1), ti.MessageLimit) + require.Equal(t, time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(1), ti.EmailLimit) + require.Equal(t, int64(1), ti.ReservationLimit) + require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) + require.Equal(t, time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + + ti = tiers[1] + require.Equal(t, "pro", ti.Code) + require.Equal(t, "Pro", ti.Name) + require.Equal(t, int64(123), ti.MessageLimit) + require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(999999), ti.EmailLimit) // Updatedd! + require.Equal(t, int64(2), ti.ReservationLimit) + require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) + require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + + ti, err = a.TierByStripePrice("price_1") + require.Nil(t, err) + require.Equal(t, "supporter", ti.Code) + require.Equal(t, "Supporter", ti.Name) + require.Equal(t, int64(1), ti.MessageLimit) + require.Equal(t, time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(1), ti.EmailLimit) + require.Equal(t, int64(1), ti.ReservationLimit) + require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) + require.Equal(t, time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + + // Cannot remove tier, since user has this tier + require.Error(t, a.RemoveTier("pro")) + + // CAN remove this tier + require.Nil(t, a.RemoveTier("supporter")) + + tiers, err = a.Tiers() + require.Nil(t, err) + require.Equal(t, 1, len(tiers)) + require.Equal(t, "pro", tiers[0].Code) + require.Equal(t, "pro", tiers[0].Code) +} + +func TestAccount_Tier_Create_With_ID(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddTier(&Tier{ + ID: "ti_123", + Code: "pro", + })) + + ti, err := a.Tier("pro") + require.Nil(t, err) + require.Equal(t, "ti_123", ti.ID) +} + +func TestManager_Tier_Change_And_Reset(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + // Create tier and user + require.Nil(t, a.AddTier(&Tier{ + Code: "supporter", + Name: "Supporter", + ReservationLimit: 3, + })) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "Pro", + ReservationLimit: 4, + })) + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.ChangeTier("phil", "pro")) + + // Add 10 reservations (pro tier allows that) + for i := 0; i < 4; i++ { + require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite)) + } + + // Downgrading will not work (too many reservations) + require.Equal(t, ErrTooManyReservations, a.ChangeTier("phil", "supporter")) + + // Downgrade after removing a reservation + require.Nil(t, a.RemoveReservations("phil", "topic0")) + require.Nil(t, a.ChangeTier("phil", "supporter")) + + // Resetting will not work (too many reservations) + require.Equal(t, ErrTooManyReservations, a.ResetTier("phil")) + + // Resetting after removing all reservations + require.Nil(t, a.RemoveReservations("phil", "topic1", "topic2", "topic3")) + require.Nil(t, a.ResetTier("phil")) +} + +func TestUser_PhoneNumberAddListRemove(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + + phoneNumbers, err := a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+1234567890", phoneNumbers[0]) + + require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) + phoneNumbers, err = a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) + + // Paranoia check: We do NOT want to keep phone numbers in there + rows, err := a.db.Query(`SELECT * FROM user_phone`) + require.Nil(t, err) + require.False(t, rows.Next()) + require.Nil(t, rows.Close()) +} + +func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) +} + +func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + require.Nil(t, a.AllowAccess(Everyone, "*_", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "__*_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "allowed_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "__allowed_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "_allowed_", PermissionRead)) // The "%" in "%\_" matches the first "_" + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "notallowed", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "_notallowed", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "__notallowed", PermissionRead)) +} + +func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + require.Nil(t, a.AllowAccess(Everyone, "mytopic_", PermissionReadWrite)) + require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "mytopic_", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) +} + +func TestToFromSQLWildcard(t *testing.T) { + require.Equal(t, "up%", toSQLWildcard("up*")) + require.Equal(t, "up\\_%", toSQLWildcard("up_*")) + require.Equal(t, "foo", toSQLWildcard("foo")) + + require.Equal(t, "up*", fromSQLWildcard("up%")) + require.Equal(t, "up_*", fromSQLWildcard("up\\_%")) + require.Equal(t, "foo", fromSQLWildcard("foo")) + + require.Equal(t, "up*", fromSQLWildcard(toSQLWildcard("up*"))) + require.Equal(t, "up_*", fromSQLWildcard(toSQLWildcard("up_*"))) + require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo"))) +} + +func TestMigrationFrom1(t *testing.T) { + filename := filepath.Join(t.TempDir(), "user.db") + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 1" schema + _, err = db.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS user ( + user TEXT NOT NULL PRIMARY KEY, + pass TEXT NOT NULL, + role TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS access ( + user TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + PRIMARY KEY (topic, user) + ); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schemaVersion (id, version) VALUES (1, 1); + COMMIT; + `) + require.Nil(t, err) + + // Insert a bunch of users and ACL entries + _, err = db.Exec(` + BEGIN; + INSERT INTO user (user, pass, role) VALUES ('ben', '$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy', 'user'); + INSERT INTO user (user, pass, role) VALUES ('phil', '$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C', 'admin'); + INSERT INTO access (user, topic, read, write) VALUES ('ben', 'stats', 1, 1); + INSERT INTO access (user, topic, read, write) VALUES ('ben', 'secret', 1, 0); + INSERT INTO access (user, topic, read, write) VALUES ('*', 'stats', 1, 0); + COMMIT; + `) + require.Nil(t, err) + + // Create manager to trigger migration + a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) + checkSchemaVersion(t, a.db) + + users, err := a.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + phil, ben, everyone := users[0], users[1], users[2] + + philGrants, err := a.Grants("phil") + require.Nil(t, err) + + benGrants, err := a.Grants("ben") + require.Nil(t, err) + + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + + require.True(t, strings.HasPrefix(phil.ID, "u_")) + require.Equal(t, "phil", phil.Name) + require.Equal(t, RoleAdmin, phil.Role) + require.Equal(t, syncTopicLength, len(phil.SyncTopic)) + require.Equal(t, 0, len(philGrants)) + + require.True(t, strings.HasPrefix(ben.ID, "u_")) + require.NotEqual(t, phil.ID, ben.ID) + require.Equal(t, "ben", ben.Name) + require.Equal(t, RoleUser, ben.Role) + require.Equal(t, syncTopicLength, len(ben.SyncTopic)) + require.NotEqual(t, ben.SyncTopic, phil.SyncTopic) + require.Equal(t, 2, len(benGrants)) + require.Equal(t, "stats", benGrants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, benGrants[0].Allow) + require.Equal(t, "secret", benGrants[1].TopicPattern) + require.Equal(t, PermissionRead, benGrants[1].Allow) + + require.Equal(t, "u_everyone", everyone.ID) + require.Equal(t, Everyone, everyone.Name) + require.Equal(t, RoleAnonymous, everyone.Role) + require.Equal(t, 1, len(everyoneGrants)) + require.Equal(t, "stats", everyoneGrants[0].TopicPattern) + require.Equal(t, PermissionRead, everyoneGrants[0].Allow) +} + +func TestMigrationFrom4(t *testing.T) { + filename := filepath.Join(t.TempDir(), "user.db") + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 4" schema + _, err = db.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + calls_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_monthly_price_id TEXT, + stripe_yearly_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); + CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); + CREATE TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO user (id, user, pass, role, sync_topic, created) + VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) + ON CONFLICT (id) DO NOTHING; + INSERT INTO schemaVersion (id, version) VALUES (1, 4); + COMMIT; + `) + require.Nil(t, err) + + // Insert a few ACL entries + _, err = db.Exec(` + BEGIN; + INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1); + INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1); + INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1); + COMMIT; + `) + require.Nil(t, err) + + // Create manager to trigger migration + a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) + checkSchemaVersion(t, a.db) + + // Add another + require.Nil(t, a.AllowAccess(Everyone, "left_*", PermissionReadWrite)) + + // Check "external view" of grants + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + + require.Equal(t, 4, len(everyoneGrants)) + require.Equal(t, "down_*", everyoneGrants[0].TopicPattern) + require.Equal(t, "left_*", everyoneGrants[1].TopicPattern) + require.Equal(t, "mytopic_", everyoneGrants[2].TopicPattern) + require.Equal(t, "up*", everyoneGrants[3].TopicPattern) + + // Check they are stored correctly in the database + rows, err := db.Query(`SELECT topic FROM user_access WHERE user_id = 'u_everyone' ORDER BY topic`) + require.Nil(t, err) + topicPatterns := make([]string, 0) + for rows.Next() { + var topicPattern string + require.Nil(t, rows.Scan(&topicPattern)) + topicPatterns = append(topicPatterns, topicPattern) + } + require.Nil(t, rows.Close()) + require.Equal(t, 4, len(topicPatterns)) + require.Equal(t, "down\\_%", topicPatterns[0]) + require.Equal(t, "left\\_%", topicPatterns[1]) + require.Equal(t, "mytopic\\_", topicPatterns[2]) + require.Equal(t, "up%", topicPatterns[3]) + + // Check that ACL works as excepted + require.Nil(t, a.Authorize(nil, "down_123", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "downX123", PermissionRead)) + + require.Nil(t, a.Authorize(nil, "left_abc", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "leftX123", PermissionRead)) + + require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead)) + + require.Nil(t, a.Authorize(nil, "up123", PermissionRead)) + require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters +} + +func checkSchemaVersion(t *testing.T, db *sql.DB) { + rows, err := db.Query(`SELECT version FROM schemaVersion`) + require.Nil(t, err) + require.True(t, rows.Next()) + + var schemaVersion int + require.Nil(t, rows.Scan(&schemaVersion)) + require.Equal(t, currentSchemaVersion, schemaVersion) + require.Nil(t, rows.Close()) +} + +func newTestManager(t *testing.T, defaultAccess Permission) *Manager { + return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) +} + +func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { + a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval) + require.Nil(t, err) + return a +} diff --git a/user/types.go b/user/types.go new file mode 100644 index 00000000..140da216 --- /dev/null +++ b/user/types.go @@ -0,0 +1,287 @@ +package user + +import ( + "errors" + "git.zio.sh/astra/ntfy/v2/log" + "github.com/stripe/stripe-go/v74" + "net/netip" + "regexp" + "strings" + "time" +) + +// User is a struct that represents a user +type User struct { + ID string + Name string + Hash string // password hash (bcrypt) + Token string // Only set if token was used to log in + Role Role + Prefs *Prefs + Tier *Tier + Stats *Stats + Billing *Billing + SyncTopic string + Deleted bool +} + +// TierID returns the ID of the User.Tier, or an empty string if the user has no tier, +// or if the user itself is nil. +func (u *User) TierID() string { + if u == nil || u.Tier == nil { + return "" + } + return u.Tier.ID +} + +// IsAdmin returns true if the user is an admin +func (u *User) IsAdmin() bool { + return u != nil && u.Role == RoleAdmin +} + +// IsUser returns true if the user is a regular user, not an admin +func (u *User) IsUser() bool { + return u != nil && u.Role == RoleUser +} + +// Auther is an interface for authentication and authorization +type Auther interface { + // Authenticate checks username and password and returns a user if correct. The method + // returns in constant-ish time, regardless of whether the user exists or the password is + // correct or incorrect. + Authenticate(username, password string) (*User, error) + + // Authorize returns nil if the given user has access to the given topic using the desired + // permission. The user param may be nil to signal an anonymous user. + Authorize(user *User, topic string, perm Permission) error +} + +// Token represents a user token, including expiry date +type Token struct { + Value string + Label string + LastAccess time.Time + LastOrigin netip.Addr + Expires time.Time +} + +// TokenUpdate holds information about the last access time and origin IP address of a token +type TokenUpdate struct { + LastAccess time.Time + LastOrigin netip.Addr +} + +// Prefs represents a user's configuration settings +type Prefs struct { + Language *string `json:"language,omitempty"` + Notification *NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*Subscription `json:"subscriptions,omitempty"` +} + +// Tier represents a user's account type, including its account limits +type Tier struct { + ID string // Tier identifier (ti_...) + Code string // Code of the tier + Name string // Name of the tier + MessageLimit int64 // Daily message limit + MessageExpiryDuration time.Duration // Cache duration for messages + EmailLimit int64 // Daily email limit + CallLimit int64 // Daily phone call limit + ReservationLimit int64 // Number of topic reservations allowed by user + AttachmentFileSizeLimit int64 // Max file size per file (bytes) + AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) + AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted + AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user + StripeMonthlyPriceID string // Monthly price ID for paid tiers (price_...) + StripeYearlyPriceID string // Yearly price ID for paid tiers (price_...) +} + +// Context returns fields for the log +func (t *Tier) Context() log.Context { + return log.Context{ + "tier_id": t.ID, + "tier_code": t.Code, + "stripe_monthly_price_id": t.StripeMonthlyPriceID, + "stripe_yearly_price_id": t.StripeYearlyPriceID, + } +} + +// Subscription represents a user's topic subscription +type Subscription struct { + BaseURL string `json:"base_url"` + Topic string `json:"topic"` + DisplayName *string `json:"display_name"` +} + +// Context returns fields for the log +func (s *Subscription) Context() log.Context { + return log.Context{ + "base_url": s.BaseURL, + "topic": s.Topic, + } +} + +// NotificationPrefs represents the user's notification settings +type NotificationPrefs struct { + Sound *string `json:"sound,omitempty"` + MinPriority *int `json:"min_priority,omitempty"` + DeleteAfter *int `json:"delete_after,omitempty"` +} + +// Stats is a struct holding daily user statistics +type Stats struct { + Messages int64 + Emails int64 + Calls int64 +} + +// Billing is a struct holding a user's billing information +type Billing struct { + StripeCustomerID string + StripeSubscriptionID string + StripeSubscriptionStatus stripe.SubscriptionStatus + StripeSubscriptionInterval stripe.PriceRecurringInterval + StripeSubscriptionPaidUntil time.Time + StripeSubscriptionCancelAt time.Time +} + +// Grant is a struct that represents an access control entry to a topic by a user +type Grant struct { + TopicPattern string // May include wildcard (*) + Allow Permission +} + +// Reservation is a struct that represents the ownership over a topic by a user +type Reservation struct { + Topic string + Owner Permission + Everyone Permission +} + +// Permission represents a read or write permission to a topic +type Permission uint8 + +// Permissions to a topic +const ( + PermissionDenyAll Permission = iota + PermissionRead + PermissionWrite + PermissionReadWrite // 3! +) + +// NewPermission is a helper to create a Permission based on read/write bool values +func NewPermission(read, write bool) Permission { + p := uint8(0) + if read { + p |= uint8(PermissionRead) + } + if write { + p |= uint8(PermissionWrite) + } + return Permission(p) +} + +// ParsePermission parses the string representation and returns a Permission +func ParsePermission(s string) (Permission, error) { + switch strings.ToLower(s) { + case "read-write", "rw": + return NewPermission(true, true), nil + case "read-only", "read", "ro": + return NewPermission(true, false), nil + case "write-only", "write", "wo": + return NewPermission(false, true), nil + case "deny-all", "deny", "none": + return NewPermission(false, false), nil + default: + return NewPermission(false, false), errors.New("invalid permission") + } +} + +// IsRead returns true if readable +func (p Permission) IsRead() bool { + return p&PermissionRead != 0 +} + +// IsWrite returns true if writable +func (p Permission) IsWrite() bool { + return p&PermissionWrite != 0 +} + +// IsReadWrite returns true if readable and writable +func (p Permission) IsReadWrite() bool { + return p.IsRead() && p.IsWrite() +} + +// String returns a string representation of the permission +func (p Permission) String() string { + if p.IsReadWrite() { + return "read-write" + } else if p.IsRead() { + return "read-only" + } else if p.IsWrite() { + return "write-only" + } + return "deny-all" +} + +// Role represents a user's role, either admin or regular user +type Role string + +// User roles +const ( + RoleAdmin = Role("admin") // Some queries have these values hardcoded! + RoleUser = Role("user") + RoleAnonymous = Role("anonymous") +) + +// Everyone is a special username representing anonymous users +const ( + Everyone = "*" + everyoneID = "u_everyone" +) + +var ( + allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) + allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' + allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! + allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) +) + +// AllowedRole returns true if the given role can be used for new users +func AllowedRole(role Role) bool { + return role == RoleUser || role == RoleAdmin +} + +// AllowedUsername returns true if the given username is valid +func AllowedUsername(username string) bool { + return allowedUsernameRegex.MatchString(username) +} + +// AllowedTopic returns true if the given topic name is valid +func AllowedTopic(topic string) bool { + return allowedTopicRegex.MatchString(topic) +} + +// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) +func AllowedTopicPattern(topic string) bool { + return allowedTopicPatternRegex.MatchString(topic) +} + +// AllowedTier returns true if the given tier name is valid +func AllowedTier(tier string) bool { + return allowedTierRegex.MatchString(tier) +} + +// Error constants used by the package +var ( + ErrUnauthenticated = errors.New("unauthenticated") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidArgument = errors.New("invalid argument") + ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user already exists") + ErrTierNotFound = errors.New("tier not found") + ErrTokenNotFound = errors.New("token not found") + ErrPhoneNumberNotFound = errors.New("phone number not found") + ErrTooManyReservations = errors.New("new tier has lower reservation limit") + ErrPhoneNumberExists = errors.New("phone number already exists") +) diff --git a/user/types_test.go b/user/types_test.go new file mode 100644 index 00000000..811d33f2 --- /dev/null +++ b/user/types_test.go @@ -0,0 +1,63 @@ +package user + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestPermission(t *testing.T) { + require.Equal(t, PermissionReadWrite, NewPermission(true, true)) + require.Equal(t, PermissionRead, NewPermission(true, false)) + require.Equal(t, PermissionWrite, NewPermission(false, true)) + require.Equal(t, PermissionDenyAll, NewPermission(false, false)) + require.True(t, PermissionReadWrite.IsReadWrite()) + require.True(t, PermissionReadWrite.IsRead()) + require.True(t, PermissionReadWrite.IsWrite()) + require.True(t, PermissionRead.IsRead()) + require.True(t, PermissionWrite.IsWrite()) +} + +func TestParsePermission(t *testing.T) { + _, err := ParsePermission("no") + require.NotNil(t, err) + + p, err := ParsePermission("read-write") + require.Nil(t, err) + require.Equal(t, PermissionReadWrite, p) + + p, err = ParsePermission("rw") + require.Nil(t, err) + require.Equal(t, PermissionReadWrite, p) + + p, err = ParsePermission("read-only") + require.Nil(t, err) + require.Equal(t, PermissionRead, p) + + p, err = ParsePermission("WRITE") + require.Nil(t, err) + require.Equal(t, PermissionWrite, p) + + p, err = ParsePermission("deny-all") + require.Nil(t, err) + require.Equal(t, PermissionDenyAll, p) +} + +func TestAllowedTier(t *testing.T) { + require.False(t, AllowedTier(" no")) + require.True(t, AllowedTier("yes")) +} + +func TestTierContext(t *testing.T) { + tier := &Tier{ + ID: "ti_abc", + Code: "pro", + StripeMonthlyPriceID: "price_123", + StripeYearlyPriceID: "price_456", + } + context := tier.Context() + require.Equal(t, "ti_abc", context["tier_id"]) + require.Equal(t, "pro", context["tier_code"]) + require.Equal(t, "price_123", context["stripe_monthly_price_id"]) + require.Equal(t, "price_456", context["stripe_yearly_price_id"]) + +} diff --git a/util/batching_queue.go b/util/batching_queue.go new file mode 100644 index 00000000..85ba9be9 --- /dev/null +++ b/util/batching_queue.go @@ -0,0 +1,86 @@ +package util + +import ( + "sync" + "time" +) + +// BatchingQueue is a queue that creates batches of the enqueued elements based on a +// max batch size and a batch timeout. +// +// Example: +// +// q := NewBatchingQueue[int](2, 500 * time.Millisecond) +// go func() { +// for batch := range q.Dequeue() { +// fmt.Println(batch) +// } +// }() +// q.Enqueue(1) +// q.Enqueue(2) +// q.Enqueue(3) +// time.Sleep(time.Second) +// +// This example will emit batch [1, 2] immediately (because the batch size is 2), and +// a batch [3] after 500ms. +type BatchingQueue[T any] struct { + batchSize int + timeout time.Duration + in []T + out chan []T + mu sync.Mutex +} + +// NewBatchingQueue creates a new BatchingQueue +func NewBatchingQueue[T any](batchSize int, timeout time.Duration) *BatchingQueue[T] { + q := &BatchingQueue[T]{ + batchSize: batchSize, + timeout: timeout, + in: make([]T, 0), + out: make(chan []T), + } + go q.timeoutTicker() + return q +} + +// Enqueue enqueues an element to the queue. If the configured batch size is reached, +// the batch will be emitted immediately. +func (q *BatchingQueue[T]) Enqueue(element T) { + q.mu.Lock() + q.in = append(q.in, element) + var elements []T + if len(q.in) == q.batchSize { + elements = q.dequeueAll() + } + q.mu.Unlock() + if len(elements) > 0 { + q.out <- elements + } +} + +// Dequeue returns a channel emitting batches of elements +func (q *BatchingQueue[T]) Dequeue() <-chan []T { + return q.out +} + +func (q *BatchingQueue[T]) dequeueAll() []T { + elements := make([]T, len(q.in)) + copy(elements, q.in) + q.in = q.in[:0] + return elements +} + +func (q *BatchingQueue[T]) timeoutTicker() { + if q.timeout == 0 { + return + } + ticker := time.NewTicker(q.timeout) + for range ticker.C { + q.mu.Lock() + elements := q.dequeueAll() + q.mu.Unlock() + if len(elements) > 0 { + q.out <- elements + } + } +} diff --git a/util/batching_queue_test.go b/util/batching_queue_test.go new file mode 100644 index 00000000..08d812ed --- /dev/null +++ b/util/batching_queue_test.go @@ -0,0 +1,58 @@ +package util_test + +import ( + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "math/rand" + "sync" + "testing" + "time" +) + +func TestBatchingQueue_InfTimeout(t *testing.T) { + q := util.NewBatchingQueue[int](25, 1*time.Hour) + batches, total := make([][]int, 0), 0 + var mu sync.Mutex + go func() { + for batch := range q.Dequeue() { + mu.Lock() + batches = append(batches, batch) + total += len(batch) + mu.Unlock() + } + }() + for i := 0; i < 101; i++ { + go q.Enqueue(i) + } + time.Sleep(time.Second) + mu.Lock() + require.Equal(t, 100, total) // One is missing, stuck in the last batch! + require.Equal(t, 4, len(batches)) + mu.Unlock() +} + +func TestBatchingQueue_WithTimeout(t *testing.T) { + q := util.NewBatchingQueue[int](25, 100*time.Millisecond) + batches, total := make([][]int, 0), 0 + var mu sync.Mutex + go func() { + for batch := range q.Dequeue() { + mu.Lock() + batches = append(batches, batch) + total += len(batch) + mu.Unlock() + } + }() + for i := 0; i < 101; i++ { + go func(i int) { + time.Sleep(time.Duration(rand.Intn(700)) * time.Millisecond) + q.Enqueue(i) + }(i) + } + time.Sleep(time.Second) + mu.Lock() + require.Equal(t, 101, total) + require.True(t, len(batches) > 4) // 101/25 + require.True(t, len(batches) < 21) + mu.Unlock() +} diff --git a/util/content_type_writer_test.go b/util/content_type_writer_test.go index 0fdf65cc..e30e821b 100644 --- a/util/content_type_writer_test.go +++ b/util/content_type_writer_test.go @@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) { rr := httptest.NewRecorder() sw := NewContentTypeWriter(rr, "") randomBytes := make([]byte, 199) - rand.Read(randomBytes) + rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings sw.Write(randomBytes) require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type")) } diff --git a/util/embedfs.go b/util/embedfs.go index 58c4529d..b75bf6ca 100644 --- a/util/embedfs.go +++ b/util/embedfs.go @@ -11,14 +11,13 @@ import ( // CachingEmbedFS is a wrapper around embed.FS that allows setting a ModTime, so that the // default static file server can send 304s back. It can be used like this: // -// var ( -// //go:embed docs -// docsStaticFs embed.FS -// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} -// ) -// -// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r) +// var ( +// //go:embed docs +// docsStaticFs embed.FS +// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} +// ) // +// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r) type CachingEmbedFS struct { ModTime time.Time FS embed.FS diff --git a/util/gzip_handler.go b/util/gzip_handler.go index 613df48e..9b30fa8a 100644 --- a/util/gzip_handler.go +++ b/util/gzip_handler.go @@ -3,7 +3,6 @@ package util import ( "compress/gzip" "io" - "io/ioutil" "net/http" "strings" "sync" @@ -31,8 +30,8 @@ func Gzip(next http.Handler) http.Handler { } var gzPool = sync.Pool{ - New: func() interface{} { - w := gzip.NewWriter(ioutil.Discard) + New: func() any { + w := gzip.NewWriter(io.Discard) return w }, } diff --git a/util/limit.go b/util/limit.go index 8df768ad..ad2118c7 100644 --- a/util/limit.go +++ b/util/limit.go @@ -13,8 +13,17 @@ var ErrLimitReached = errors.New("limit reached") // Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value type Limiter interface { - // Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached - Allow(n int64) error + // Allow adds one to the limiters value, or returns false if the limit has been reached + Allow() bool + + // AllowN adds n to the limiters value, or returns false if the limit has been reached + AllowN(n int64) bool + + // Value returns the current internal limiter value + Value() int64 + + // Reset resets the state of the limiter + Reset() } // FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached @@ -25,33 +34,78 @@ type FixedLimiter struct { mu sync.Mutex } +var _ Limiter = (*FixedLimiter)(nil) + // NewFixedLimiter creates a new Limiter func NewFixedLimiter(limit int64) *FixedLimiter { + return NewFixedLimiterWithValue(limit, 0) +} + +// NewFixedLimiterWithValue creates a new Limiter and sets the initial value +func NewFixedLimiterWithValue(limit, value int64) *FixedLimiter { return &FixedLimiter{ limit: limit, + value: value, } } -// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was -// exceeded after adding n, ErrLimitReached is returned. -func (l *FixedLimiter) Allow(n int64) error { +// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was +// exceeded, false is returned. +func (l *FixedLimiter) Allow() bool { + return l.AllowN(1) +} + +// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was +// exceeded after adding n, false is returned. +func (l *FixedLimiter) AllowN(n int64) bool { l.mu.Lock() defer l.mu.Unlock() if l.value+n > l.limit { - return ErrLimitReached + return false } l.value += n - return nil + return true +} + +// Value returns the current limiter value +func (l *FixedLimiter) Value() int64 { + l.mu.Lock() + defer l.mu.Unlock() + return l.value +} + +// Reset sets the limiter's value back to zero +func (l *FixedLimiter) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.value = 0 } // RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit. type RateLimiter struct { + r rate.Limit + b int + value int64 limiter *rate.Limiter + mu sync.Mutex } +var _ Limiter = (*RateLimiter)(nil) + // NewRateLimiter creates a new RateLimiter func NewRateLimiter(r rate.Limit, b int) *RateLimiter { + return NewRateLimiterWithValue(r, b, 0) +} + +// NewRateLimiterWithValue creates a new RateLimiter with the given starting value. +// +// Note that the starting value only has informational value. It does not impact the underlying +// value of the rate.Limiter. +func NewRateLimiterWithValue(r rate.Limit, b int, value int64) *RateLimiter { return &RateLimiter{ + r: r, + b: b, + value: value, limiter: rate.NewLimiter(r, b), } } @@ -62,16 +116,40 @@ func NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter { return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes) } -// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was -// exceeded after adding n, ErrLimitReached is returned. -func (l *RateLimiter) Allow(n int64) error { +// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was +// exceeded, false is returned. +func (l *RateLimiter) Allow() bool { + return l.AllowN(1) +} + +// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was +// exceeded after adding n, false is returned. +func (l *RateLimiter) AllowN(n int64) bool { if n <= 0 { - return nil // No-op. Can't take back bytes you're written! + return false // No-op. Can't take back bytes you're written! } + l.mu.Lock() + defer l.mu.Unlock() if !l.limiter.AllowN(time.Now(), int(n)) { - return ErrLimitReached + return false } - return nil + l.value += n + return true +} + +// Value returns the current limiter value +func (l *RateLimiter) Value() int64 { + l.mu.Lock() + defer l.mu.Unlock() + return l.value +} + +// Reset sets the limiter's value back to zero, and resets the underlying rate.Limiter +func (l *RateLimiter) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.limiter = rate.NewLimiter(l.r, l.b) + l.value = 0 } // LimitWriter implements an io.Writer that will pass through all Write calls to the underlying @@ -97,9 +175,9 @@ func (w *LimitWriter) Write(p []byte) (n int, err error) { w.mu.Lock() defer w.mu.Unlock() for i := 0; i < len(w.limiters); i++ { - if err := w.limiters[i].Allow(int64(len(p))); err != nil { + if !w.limiters[i].AllowN(int64(len(p))) { for j := i - 1; j >= 0; j-- { - w.limiters[j].Allow(-int64(len(p))) // Revert limiters limits if allowed + w.limiters[j].AllowN(-int64(len(p))) // Revert limiters limits if not allowed } return 0, ErrLimitReached } diff --git a/util/limit_test.go b/util/limit_test.go index 53e10b78..51595351 100644 --- a/util/limit_test.go +++ b/util/limit_test.go @@ -7,26 +7,31 @@ import ( "time" ) -func TestFixedLimiter_Add(t *testing.T) { +func TestFixedLimiter_AllowValueReset(t *testing.T) { l := NewFixedLimiter(10) - if err := l.Allow(5); err != nil { - t.Fatal(err) - } - if err := l.Allow(5); err != nil { - t.Fatal(err) - } - if err := l.Allow(5); err != ErrLimitReached { - t.Fatalf("expected ErrLimitReached, got %#v", err) - } + require.True(t, l.AllowN(5)) + require.Equal(t, int64(5), l.Value()) + + require.True(t, l.AllowN(5)) + require.Equal(t, int64(10), l.Value()) + + require.False(t, l.Allow()) + require.Equal(t, int64(10), l.Value()) + + l.Reset() + require.Equal(t, int64(0), l.Value()) + require.True(t, l.Allow()) + require.True(t, l.AllowN(9)) + require.False(t, l.Allow()) } func TestFixedLimiter_AddSub(t *testing.T) { l := NewFixedLimiter(10) - l.Allow(5) + l.AllowN(5) if l.value != 5 { t.Fatalf("expected value to be %d, got %d", 5, l.value) } - l.Allow(-2) + l.AllowN(-2) if l.value != 3 { t.Fatalf("expected value to be %d, got %d", 7, l.value) } @@ -34,17 +39,22 @@ func TestFixedLimiter_AddSub(t *testing.T) { func TestBytesLimiter_Add_Simple(t *testing.T) { l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h - require.Nil(t, l.Allow(100*1024*1024)) - require.Nil(t, l.Allow(100*1024*1024)) - require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024)) + require.True(t, l.AllowN(100*1024*1024)) + require.Equal(t, int64(100*1024*1024), l.Value()) + + require.True(t, l.AllowN(100*1024*1024)) + require.Equal(t, int64(200*1024*1024), l.Value()) + + require.False(t, l.AllowN(300*1024*1024)) + require.Equal(t, int64(200*1024*1024), l.Value()) } func TestBytesLimiter_Add_Wait(t *testing.T) { l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms) - require.Nil(t, l.Allow(250*1024*1024)) - require.Equal(t, ErrLimitReached, l.Allow(400)) + require.True(t, l.AllowN(250*1024*1024)) + require.False(t, l.AllowN(400)) time.Sleep(200 * time.Millisecond) - require.Nil(t, l.Allow(400)) + require.True(t, l.AllowN(400)) } func TestLimitWriter_WriteNoLimiter(t *testing.T) { diff --git a/util/lookup_cache.go b/util/lookup_cache.go new file mode 100644 index 00000000..e3f086c7 --- /dev/null +++ b/util/lookup_cache.go @@ -0,0 +1,56 @@ +package util + +import ( + "sync" + "time" +) + +// LookupCache is a single-value cache with a time-to-live (TTL). The cache has a lookup function +// to retrieve the value and stores it until TTL is reached. +// +// Example: +// +// lookup := func() (string, error) { +// r, _ := http.Get("...") +// s, _ := io.ReadAll(r.Body) +// return string(s), nil +// } +// c := NewLookupCache[string](lookup, time.Hour) +// fmt.Println(c.Get()) // Fetches the string via HTTP +// fmt.Println(c.Get()) // Uses cached value +type LookupCache[T any] struct { + value *T + lookup func() (T, error) + ttl time.Duration + updated time.Time + mu sync.Mutex +} + +// LookupFunc is a function that is called by the LookupCache if the underlying +// value is out-of-date. It returns the new value, or an error. +type LookupFunc[T any] func() (T, error) + +// NewLookupCache creates a new LookupCache with a given time-to-live (TTL) +func NewLookupCache[T any](lookup LookupFunc[T], ttl time.Duration) *LookupCache[T] { + return &LookupCache[T]{ + value: nil, + lookup: lookup, + ttl: ttl, + } +} + +// Value returns the cached value, or retrieves it via the lookup function +func (c *LookupCache[T]) Value() (T, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.value == nil || (c.ttl > 0 && time.Since(c.updated) > c.ttl) { + value, err := c.lookup() + if err != nil { + var t T + return t, err + } + c.value = &value + c.updated = time.Now() + } + return *c.value, nil +} diff --git a/util/lookup_cache_test.go b/util/lookup_cache_test.go new file mode 100644 index 00000000..5d45af34 --- /dev/null +++ b/util/lookup_cache_test.go @@ -0,0 +1,63 @@ +package util + +import ( + "errors" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestLookupCache_Success(t *testing.T) { + values, i := []string{"first", "second"}, 0 + c := NewLookupCache[string](func() (string, error) { + time.Sleep(300 * time.Millisecond) + v := values[i] + i++ + return v, nil + }, 500*time.Millisecond) + + start := time.Now() + v, err := c.Value() + require.Nil(t, err) + require.Equal(t, values[0], v) + require.True(t, time.Since(start) >= 300*time.Millisecond) + + start = time.Now() + v, err = c.Value() + require.Nil(t, err) + require.Equal(t, values[0], v) + require.True(t, time.Since(start) < 200*time.Millisecond) + + time.Sleep(550 * time.Millisecond) + + start = time.Now() + v, err = c.Value() + require.Nil(t, err) + require.Equal(t, values[1], v) + require.True(t, time.Since(start) >= 300*time.Millisecond) + + start = time.Now() + v, err = c.Value() + require.Nil(t, err) + require.Equal(t, values[1], v) + require.True(t, time.Since(start) < 200*time.Millisecond) +} + +func TestLookupCache_Error(t *testing.T) { + c := NewLookupCache[string](func() (string, error) { + time.Sleep(200 * time.Millisecond) + return "", errors.New("some error") + }, 500*time.Millisecond) + + start := time.Now() + v, err := c.Value() + require.NotNil(t, err) + require.Equal(t, "", v) + require.True(t, time.Since(start) >= 200*time.Millisecond) + + start = time.Now() + v, err = c.Value() + require.NotNil(t, err) + require.Equal(t, "", v) + require.True(t, time.Since(start) >= 200*time.Millisecond) +} diff --git a/util/peek.go b/util/peek.go index f7219253..40150cbc 100644 --- a/util/peek.go +++ b/util/peek.go @@ -18,7 +18,8 @@ type PeekedReadCloser struct { closed bool } -// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser +// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser. +// It does not return an error if limit is reached. Instead, LimitReached will be set to true. func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) { if underlying == nil { underlying = io.NopCloser(strings.NewReader("")) diff --git a/util/time.go b/util/time.go index 70501210..14aa3936 100644 --- a/util/time.go +++ b/util/time.go @@ -14,6 +14,27 @@ var ( durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`) ) +const ( + timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds +) + +// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds +func FormatTime(t time.Time) string { + return t.Format(timestampFormat) +} + +// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence +// of that time from the current time (in UTC). +func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time { + hour, minute, seconds := timeOfDay.UTC().Clock() + now := base.UTC() + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC) + if next.Before(now) { + next = next.AddDate(0, 0, 1) + } + return next +} + // ParseFutureTime parses a date/time string to a time.Time. It supports unix timestamps, durations // and natural language dates func ParseFutureTime(s string, now time.Time) (time.Time, error) { @@ -33,15 +54,9 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) { return time.Time{}, errUnparsableTime } -func parseFromDuration(s string, now time.Time) (time.Time, error) { - d, err := parseDuration(s) - if err == nil { - return now.Add(d), nil - } - return time.Time{}, errUnparsableTime -} - -func parseDuration(s string) (time.Duration, error) { +// ParseDuration is like time.ParseDuration, except that it also understands days (d), which +// translates to 24 hours, e.g. "2d" or "20h". +func ParseDuration(s string) (time.Duration, error) { d, err := time.ParseDuration(s) if err == nil { return d, nil @@ -68,6 +83,14 @@ func parseDuration(s string) (time.Duration, error) { return 0, errUnparsableTime } +func parseFromDuration(s string, now time.Time) (time.Time, error) { + d, err := ParseDuration(s) + if err == nil { + return now.Add(d), nil + } + return time.Time{}, errUnparsableTime +} + func parseUnixTime(s string, now time.Time) (time.Time, error) { t, err := strconv.Atoi(s) if err != nil { diff --git a/util/time_test.go b/util/time_test.go index 9cab5046..9cc343fd 100644 --- a/util/time_test.go +++ b/util/time_test.go @@ -11,6 +11,26 @@ var ( base = time.Date(2021, 12, 10, 10, 17, 23, 0, time.UTC) ) +func TestNextOccurrenceUTC_NextDate(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + require.Nil(t, err) + + timeOfDay := time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) // Run at midnight UTC + nowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc) + nextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT) + require.Equal(t, time.Date(2023, time.January, 12, 0, 0, 0, 0, time.UTC), nextRunTme) +} + +func TestNextOccurrenceUTC_SameDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + require.Nil(t, err) + + timeOfDay := time.Date(0, 0, 0, 4, 0, 0, 0, time.UTC) // Run at 4am UTC + nowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc) + nextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT) + require.Equal(t, time.Date(2023, time.January, 11, 4, 0, 0, 0, time.UTC), nextRunTme) +} + func TestParseFutureTime_11am_FutureTime(t *testing.T) { d, err := ParseFutureTime("11am", base) require.Nil(t, err) @@ -58,3 +78,17 @@ func TestParseFutureTime_UnixTime(t *testing.T) { require.Nil(t, err) require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d) } + +func TestParseDuration(t *testing.T) { + d, err := ParseDuration("2d") + require.Nil(t, err) + require.Equal(t, 48*time.Hour, d) + + d, err = ParseDuration("2h") + require.Nil(t, err) + require.Equal(t, 2*time.Hour, d) + + d, err = ParseDuration("0") + require.Nil(t, err) + require.Equal(t, time.Duration(0), d) +} diff --git a/util/util.go b/util/util.go index 3919d3e2..d48487df 100644 --- a/util/util.go +++ b/util/util.go @@ -1,23 +1,30 @@ package util import ( + "bytes" "encoding/base64" + "encoding/json" "errors" "fmt" - "github.com/gabriel-vasile/mimetype" - "golang.org/x/term" "io" "math/rand" + "net/netip" "os" "regexp" "strconv" "strings" "sync" "time" + + "golang.org/x/time/rate" + + "github.com/gabriel-vasile/mimetype" + "golang.org/x/term" ) const ( - randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + randomStringLowerCaseCharset = "abcdefghijklmnopqrstuvwxyz0123456789" ) var ( @@ -25,6 +32,13 @@ var ( randomMutex = sync.Mutex{} sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) errInvalidPriority = errors.New("invalid priority") + noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`) +) + +// Errors for UnmarshalJSON and UnmarshalJSONWithLimit functions +var ( + ErrUnmarshalJSON = errors.New("unmarshalling JSON failed") + ErrTooLargeJSON = errors.New("too large JSON") ) // FileExists checks if a file exists, and returns true if it does @@ -33,8 +47,8 @@ func FileExists(filename string) bool { return stat != nil } -// InStringList returns true if needle is contained in haystack -func InStringList(haystack []string, needle string) bool { +// Contains returns true if needle is contained in haystack +func Contains[T comparable](haystack []T, needle T) bool { for _, s := range haystack { if s == needle { return true @@ -43,29 +57,26 @@ func InStringList(haystack []string, needle string) bool { return false } -// InStringListAll returns true if all needles are contained in haystack -func InStringListAll(haystack []string, needles []string) bool { - matches := 0 +// ContainsIP returns true if any one of the of prefixes contains the ip. +func ContainsIP(haystack []netip.Prefix, needle netip.Addr) bool { for _, s := range haystack { - for _, needle := range needles { - if s == needle { - matches++ - } - } - } - return matches == len(needles) -} - -// InIntList returns true if needle is contained in haystack -func InIntList(haystack []int, needle int) bool { - for _, s := range haystack { - if s == needle { + if s.Contains(needle) { return true } } return false } +// ContainsAll returns true if all needles are contained in haystack +func ContainsAll[T comparable](haystack []T, needles []T) bool { + for _, needle := range needles { + if !Contains(haystack, needle) { + return false + } + } + return true +} + // SplitNoEmpty splits a string using strings.Split, but filters out empty strings func SplitNoEmpty(s string, sep string) []string { res := make([]string, 0) @@ -87,15 +98,37 @@ func SplitKV(s string, sep string) (key string, value string) { return "", strings.TrimSpace(kv[0]) } +// LastString returns the last string in a slice, or def if s is empty +func LastString(s []string, def string) string { + if len(s) == 0 { + return def + } + return s[len(s)-1] +} + // RandomString returns a random string with a given length func RandomString(length int) string { + return RandomStringPrefix("", length) +} + +// RandomStringPrefix returns a random string with a given length, with a prefix +func RandomStringPrefix(prefix string, length int) string { + return randomStringPrefixWithCharset(prefix, length, randomStringCharset) +} + +// RandomLowerStringPrefix returns a random lowercase-only string with a given length, with a prefix +func RandomLowerStringPrefix(prefix string, length int) string { + return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset) +} + +func randomStringPrefixWithCharset(prefix string, length int, charset string) string { randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! defer randomMutex.Unlock() - b := make([]byte, length) + b := make([]byte, length-len(prefix)) for i := range b { - b[i] = randomStringCharset[random.Intn(len(randomStringCharset))] + b[i] = charset[random.Intn(len(charset))] } - return string(b) + return prefix + string(b) } // ValidRandomString returns true if the given string matches the format created by RandomString @@ -111,41 +144,10 @@ func ValidRandomString(s string, length int) bool { return true } -// DurationToHuman converts a duration to a human-readable format -func DurationToHuman(d time.Duration) (str string) { - if d == 0 { - return "0" - } - - d = d.Round(time.Second) - days := d / time.Hour / 24 - if days > 0 { - str += fmt.Sprintf("%dd", days) - } - d -= days * time.Hour * 24 - - hours := d / time.Hour - if hours > 0 { - str += fmt.Sprintf("%dh", hours) - } - d -= hours * time.Hour - - minutes := d / time.Minute - if minutes > 0 { - str += fmt.Sprintf("%dm", minutes) - } - d -= minutes * time.Minute - - seconds := d / time.Second - if seconds > 0 { - str += fmt.Sprintf("%ds", seconds) - } - return -} - // ParsePriority parses a priority string into its equivalent integer value func ParsePriority(priority string) (int, error) { - switch strings.TrimSpace(strings.ToLower(priority)) { + p := strings.TrimSpace(strings.ToLower(priority)) + switch p { case "": return 0, nil case "1", "min": @@ -183,11 +185,6 @@ func PriorityString(priority int) (string, error) { } } -// ExpandHome replaces "~" with the user's home directory -func ExpandHome(path string) string { - return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME")) -} - // ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https:// func ShortTopicURL(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") @@ -229,6 +226,20 @@ func ParseSize(s string) (int64, error) { } } +// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB +func FormatSize(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d bytes", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the // input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing). func ReadPassword(in io.Reader) ([]byte, error) { @@ -269,3 +280,114 @@ func ReadPassword(in io.Reader) ([]byte, error) { func BasicAuth(user, pass string) string { return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))) } + +// BearerAuth encodes the Authorization header value for a bearer/token auth +func BearerAuth(token string) string { + return fmt.Sprintf("Bearer %s", token) +} + +// MaybeMarshalJSON returns a JSON string of the given object, or "" if serialization failed. +// This is useful for logging purposes where a failure doesn't matter that much. +func MaybeMarshalJSON(v any) string { + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "" + } + if len(jsonBytes) > 5000 { + return string(jsonBytes)[:5000] + } + return string(jsonBytes) +} + +// QuoteCommand combines a command array to a string, quoting arguments that need quoting. +// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command. +// +// Warning: Never use this function with the intent to run the resulting command. +// +// Example: +// +// []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder" +func QuoteCommand(command []string) string { + var quoted []string + for _, c := range command { + if noQuotesRegex.MatchString(c) { + quoted = append(quoted, c) + } else { + quoted = append(quoted, fmt.Sprintf(`"%s"`, c)) + } + } + return strings.Join(quoted, " ") +} + +// UnmarshalJSON reads the given io.ReadCloser into a struct +func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) { + var obj T + if err := json.NewDecoder(body).Decode(&obj); err != nil { + return nil, ErrUnmarshalJSON + } + return &obj, nil +} + +// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached +func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { + defer r.Close() + p, err := Peek(r, limit) + if err != nil { + return nil, err + } else if p.LimitReached { + return nil, ErrTooLargeJSON + } + var obj T + if len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty { + return &obj, nil + } else if err := json.NewDecoder(p).Decode(&obj); err != nil { + return nil, ErrUnmarshalJSON + } + return &obj, nil +} + +// Retry executes function f until if succeeds, and then returns t. If f fails, it sleeps +// and tries again. The sleep durations are passed as the after params. +func Retry[T any](f func() (*T, error), after ...time.Duration) (t *T, err error) { + for _, delay := range after { + if t, err = f(); err == nil { + return t, nil + } + time.Sleep(delay) + } + return nil, err +} + +// MinMax returns value if it is between min and max, or either +// min or max if it is out of range +func MinMax[T int | int64](value, min, max T) T { + if value < min { + return min + } else if value > max { + return max + } + return value +} + +// Max returns the maximum value of the two given values +func Max[T int | int64 | rate.Limit](a, b T) T { + if a > b { + return a + } + return b +} + +// String turns a string into a pointer of a string +func String(v string) *string { + return &v +} + +// Int turns an int into a pointer of an int +func Int(v int) *int { + return &v +} + +// Time turns a time.Time into a pointer +func Time(v time.Time) *time.Time { + return &v +} diff --git a/util/util_test.go b/util/util_test.go index a3cf4a6c..f0f45c28 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -1,38 +1,20 @@ package util import ( - "github.com/stretchr/testify/require" - "io/ioutil" + "errors" + "io" + "net/netip" "os" "path/filepath" + "strings" "testing" "time" + + "golang.org/x/time/rate" + + "github.com/stretchr/testify/require" ) -func TestDurationToHuman_SevenDays(t *testing.T) { - d := 7 * 24 * time.Hour - require.Equal(t, "7d", DurationToHuman(d)) -} - -func TestDurationToHuman_MoreThanOneDay(t *testing.T) { - d := 49 * time.Hour - require.Equal(t, "2d1h", DurationToHuman(d)) -} - -func TestDurationToHuman_LessThanOneDay(t *testing.T) { - d := 17*time.Hour + 15*time.Minute - require.Equal(t, "17h15m", DurationToHuman(d)) -} - -func TestDurationToHuman_TenOfThings(t *testing.T) { - d := 10*time.Hour + 10*time.Minute + 10*time.Second - require.Equal(t, "10h10m10s", DurationToHuman(d)) -} - -func TestDurationToHuman_Zero(t *testing.T) { - require.Equal(t, "0", DurationToHuman(0)) -} - func TestRandomString(t *testing.T) { s1 := RandomString(10) s2 := RandomString(10) @@ -45,27 +27,39 @@ func TestRandomString(t *testing.T) { func TestFileExists(t *testing.T) { filename := filepath.Join(t.TempDir(), "somefile.txt") - require.Nil(t, ioutil.WriteFile(filename, []byte{0x25, 0x86}, 0600)) + require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600)) require.True(t, FileExists(filename)) require.False(t, FileExists(filename+".doesnotexist")) } func TestInStringList(t *testing.T) { s := []string{"one", "two"} - require.True(t, InStringList(s, "two")) - require.False(t, InStringList(s, "three")) + require.True(t, Contains(s, "two")) + require.False(t, Contains(s, "three")) } func TestInStringListAll(t *testing.T) { s := []string{"one", "two", "three", "four"} - require.True(t, InStringListAll(s, []string{"two", "four"})) - require.False(t, InStringListAll(s, []string{"three", "five"})) + require.True(t, ContainsAll(s, []string{"two", "four"})) + require.False(t, ContainsAll(s, []string{"three", "five"})) } -func TestInIntList(t *testing.T) { +func TestContains(t *testing.T) { s := []int{1, 2} - require.True(t, InIntList(s, 2)) - require.False(t, InIntList(s, 3)) + require.True(t, Contains(s, 2)) + require.False(t, Contains(s, 3)) +} + +func TestContainsAll(t *testing.T) { + require.True(t, ContainsAll([]int{1, 2, 3}, []int{2, 3})) + require.False(t, ContainsAll([]int{1, 1}, []int{1, 2})) +} + +func TestContainsIP(t *testing.T) { + require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("1.1.1.1"))) + require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("fd12:1234:5678::9876"))) + require.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("1.2.0.1"))) + require.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("fc00::1"))) } func TestSplitNoEmpty(t *testing.T) { @@ -75,14 +69,6 @@ func TestSplitNoEmpty(t *testing.T) { require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ",")) } -func TestExpandHome_WithTilde(t *testing.T) { - require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path")) -} - -func TestExpandHome_NoTilde(t *testing.T) { - require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path")) -} - func TestParsePriority(t *testing.T) { priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"} expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5} @@ -94,7 +80,7 @@ func TestParsePriority(t *testing.T) { } func TestParsePriority_Invalid(t *testing.T) { - priorities := []string{"-1", "6", "aa", "-"} + priorities := []string{"-1", "6", "aa", "-", "o=1"} for _, priority := range priorities { _, err := ParsePriority(priority) require.Equal(t, errInvalidPriority, err) @@ -166,3 +152,117 @@ func TestSplitKV(t *testing.T) { require.Equal(t, "mykey", key) require.Equal(t, "value=with=separator", value) } + +func TestLastString(t *testing.T) { + require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default")) + require.Equal(t, "default", LastString([]string{}, "default")) +} + +func TestQuoteCommand(t *testing.T) { + require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"})) + require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"})) + require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"})) +} + +func TestBasicAuth(t *testing.T) { + require.Equal(t, "Basic cGhpbDpwaGls", BasicAuth("phil", "phil")) +} + +func TestBearerAuth(t *testing.T) { + require.Equal(t, "Bearer sometoken", BearerAuth("sometoken")) +} + +type testJSON struct { + Name string `json:"name"` + Something int `json:"something"` +} + +func TestReadJSON_Success(t *testing.T) { + v, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`))) + require.Nil(t, err) + require.Equal(t, "some name", v.Name) + require.Equal(t, 99, v.Something) +} + +func TestReadJSON_Failure(t *testing.T) { + _, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{"na`))) + require.Equal(t, ErrUnmarshalJSON, err) +} + +func TestReadJSONWithLimit_Success(t *testing.T) { + v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100, false) + require.Nil(t, err) + require.Equal(t, "some name", v.Name) + require.Equal(t, 99, v.Something) +} + +func TestReadJSONWithLimit_FailureTooLong(t *testing.T) { + _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10, false) + require.Equal(t, ErrTooLargeJSON, err) +} + +func TestReadJSONWithLimit_AllowEmpty(t *testing.T) { + v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true) + require.Nil(t, err) + require.Equal(t, "", v.Name) + require.Equal(t, 0, v.Something) +} + +func TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) { + _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false) + require.Equal(t, ErrUnmarshalJSON, err) +} + +func TestRetry_Succeeds(t *testing.T) { + start := time.Now() + delays, i := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second}, 0 + fn := func() (*int, error) { + i++ + if i < len(delays) { + return nil, errors.New("error") + } + return Int(99), nil + } + result, err := Retry[int](fn, delays...) + require.Nil(t, err) + require.Equal(t, 99, *result) + require.True(t, time.Since(start).Milliseconds() > 150) +} + +func TestRetry_Fails(t *testing.T) { + fn := func() (*int, error) { + return nil, errors.New("fails") + } + _, err := Retry[int](fn, 10*time.Millisecond) + require.Error(t, err) +} + +func TestMinMax(t *testing.T) { + require.Equal(t, 10, MinMax(9, 10, 99)) + require.Equal(t, 99, MinMax(100, 10, 99)) + require.Equal(t, 50, MinMax(50, 10, 99)) +} + +func TestMax(t *testing.T) { + require.Equal(t, 9, Max(1, 9)) + require.Equal(t, 9, Max(9, 1)) + require.Equal(t, rate.Every(time.Minute), Max(rate.Every(time.Hour), rate.Every(time.Minute))) +} + +func TestPointerFunctions(t *testing.T) { + i, s, ti := Int(99), String("abc"), Time(time.Unix(99, 0)) + require.Equal(t, 99, *i) + require.Equal(t, "abc", *s) + require.Equal(t, time.Unix(99, 0), *ti) +} + +func TestMaybeMarshalJSON(t *testing.T) { + require.Equal(t, `"aa"`, MaybeMarshalJSON("aa")) + require.Equal(t, `[ + "aa", + "bb" +]`, MaybeMarshalJSON([]string{"aa", "bb"})) + require.Equal(t, "", MaybeMarshalJSON(func() {})) + require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000))) + +} diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 00000000..29c9584b --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1 @@ +src/app/emojis.js \ No newline at end of file diff --git a/web/.eslintrc b/web/.eslintrc new file mode 100644 index 00000000..a21221fc --- /dev/null +++ b/web/.eslintrc @@ -0,0 +1,38 @@ +{ + "extends": ["airbnb", "prettier"], + "env": { + "browser": true + }, + "globals": { + "config": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2023 + }, + "rules": { + "no-console": "off", + "class-methods-use-this": "off", + "func-style": ["error", "expression"], + "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"], + "no-await-in-loop": "error", + "import/no-cycle": "warn", + "react/prop-types": "off", + "react/destructuring-assignment": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-props-no-spreading": "off", + "react/jsx-no-duplicate-props": [ + "error", + { + "ignoreCase": false // For 's [iI]nputProps + } + ], + "react/function-component-definition": [ + "error", + { + "namedComponents": "arrow-function", + "unnamedComponents": "arrow-function" + } + ] + }, + "overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }] +} diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 00000000..802cdb8d --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,4 @@ +build/ +dist/ +public/static/langs/ +src/app/emojis.js diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..191e8c40 --- /dev/null +++ b/web/index.html @@ -0,0 +1,58 @@ + + + + + ntfy web + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/web/package-lock.json b/web/package-lock.json index ee5ae29d..7e3fcfdb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,20 +1,21 @@ { "name": "ntfy", "version": "1.0.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ntfy", "version": "1.0.0", "dependencies": { - "@emotion/react": "^11.8.2", - "@emotion/styled": "^11.8.1", + "@emotion/cache": "^11.11.0", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", - "electron-is-dev": "^2.0.0", + "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", @@ -23,68 +24,91 @@ "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", + "react-remark": "^2.1.0", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", - "stacktrace-js": "^2.0.2" + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0", + "stylis-plugin-rtl": "^2.1.1" }, "devDependencies": { - "concurrently": "^7.1.0", - "electron": "^18.2.0", - "electron-builder": "^23.0.3", - "wait-on": "^6.0.1" + "@vitejs/plugin-react": "^4.0.0", + "eslint": "^8.41.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "prettier": "^2.8.8", + "vite": "^4.3.9", + "vite-plugin-pwa": "^0.15.0" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" } }, "node_modules/@ampproject/remapping": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz", - "integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.0" + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dependencies": { - "@babel/highlight": "^7.16.7" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.7.tgz", - "integrity": "sha512-p8pdE6j0a29TNGebNm7NzYZWB3xVZJBZ7XGs42uAKzQo8VQ3F0By/cQCtUEABwIqw5zo6WA4NbmxsfzADzMKnQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", + "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", - "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", + "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "dev": true, "dependencies": { - "@ampproject/remapping": "^2.1.0", - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.9", - "@babel/helper-compilation-targets": "^7.17.7", - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helpers": "^7.17.9", - "@babel/parser": "^7.17.9", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.9", - "@babel/types": "^7.17.0", - "convert-source-map": "^1.7.0", + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", - "semver": "^6.3.0" + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -94,116 +118,82 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/eslint-parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.17.0.tgz", - "integrity": "sha512-PUEJ7ZBXbRkbq3qqM/jZ2nIuakUBqCYc7Qf52Lj7dlZ6zERnqisdHioL0l4wwQZnmskMeasqUNzLBFKs3nylXA==", - "dependencies": { - "eslint-scope": "^5.1.1", - "eslint-visitor-keys": "^2.1.0", - "semver": "^6.3.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || >=14.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.11.0", - "eslint": "^7.5.0 || ^8.0.0" - } - }, - "node_modules/@babel/eslint-parser/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==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "engines": { - "node": ">=10" - } - }, - "node_modules/@babel/eslint-parser/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } + "node_modules/@babel/core/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/@babel/generator": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.9.tgz", - "integrity": "sha512-rAdDousTwxbIxbz5I7GEQ3lUip+xVCXooZNbsydCWs3xA7ZsYOv+CFRdzGxRX78BmQHu9B1Eso59AOZQOJDEdQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "dev": true, "dependencies": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" + "@babel/types": "^7.23.0", + "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", - "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.7.tgz", - "integrity": "sha512-C6FdbRaxYjwVu/geKW4ZeQ0Q31AftgRcdSnZ5/jsH6BzCJbtvXvhpfkbkThYSuutZA7nCXpPR6AD9zd1dprMkA==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "dev": true, "dependencies": { - "@babel/helper-explode-assignable-expression": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.7.tgz", - "integrity": "sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, "dependencies": { - "@babel/compat-data": "^7.17.7", - "@babel/helper-validator-option": "^7.16.7", - "browserslist": "^4.17.5", - "semver": "^6.3.0" + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz", - "integrity": "sha512-kUjip3gruz6AJKOq5i3nC6CoCEEF/oHH3cp6tOZhB+IyyyPyW0g1Gfsxn3mkk6S08pIA2y8GQh609v9G/5sHVQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz", + "integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.17.9", - "@babel/helper-member-expression-to-functions": "^7.17.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7" + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -213,12 +203,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.17.0.tgz", - "integrity": "sha512-awO2So99wG6KnlE+TPs6rn83gCz5WlEePJDTnLEqbchMVrBeAujURVphRdigsk094VhvZehFoNOihSlcBjwsXA==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "regexpu-core": "^5.0.1" + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -228,238 +220,248 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz", - "integrity": "sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", + "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", + "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.13.0", - "@babel/helper-module-imports": "^7.12.13", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/traverse": "^7.13.0", + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2", - "semver": "^6.1.2" + "resolve": "^1.14.2" }, "peerDependencies": { - "@babel/core": "^7.4.0-0" + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", - "dependencies": { - "@babel/types": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-explode-assignable-expression": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", - "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", - "dependencies": { - "@babel/types": "^7.16.7" - }, + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", - "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, "dependencies": { - "@babel/template": "^7.16.7", - "@babel/types": "^7.17.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz", - "integrity": "sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "dev": true, "dependencies": { - "@babel/types": "^7.17.0" + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", - "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-simple-access": "^7.17.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/helper-validator-identifier": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.3", - "@babel/types": "^7.17.0" + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", - "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", - "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", - "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-wrap-function": "^7.16.8", - "@babel/types": "^7.16.8" + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", - "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", + "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-member-expression-to-functions": "^7.16.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/traverse": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-optimise-call-expression": "^7.22.5" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-simple-access": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", - "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, "dependencies": { - "@babel/types": "^7.17.0" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", - "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dev": true, "dependencies": { - "@babel/types": "^7.16.0" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, "dependencies": { - "@babel/types": "^7.16.7" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", - "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", - "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "dev": true, "dependencies": { - "@babel/helper-function-name": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.16.8", - "@babel/types": "^7.16.8" + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", - "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, "dependencies": { - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.9", - "@babel/types": "^7.17.0" + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", - "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -467,9 +469,10 @@ } }, "node_modules/@babel/parser": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.9.tgz", - "integrity": "sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -478,11 +481,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.16.7", - "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.16.7.tgz", - "integrity": "sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==", + "version": "7.22.15", + "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.22.15.tgz", + "integrity": "sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -492,13 +496,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.16.7.tgz", - "integrity": "sha512-di8vUHRdf+4aJ7ltXhaDbPoszdkh59AQtJM5soLsuHpQJdFQZOA4uGj0V2u/CZ8bJ/u8ULDL5yq6FO/bCXnKHw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz", + "integrity": "sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", - "@babel/plugin-proposal-optional-chaining": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -507,236 +512,11 @@ "@babel/core": "^7.13.0" } }, - "node_modules/@babel/plugin-proposal-async-generator-functions": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.16.8.tgz", - "integrity": "sha512-71YHIvMuiuqWJQkebWJtdhQTfd4Q4mF76q2IX37uZPkG9+olBxsX+rH1vkhFto4UeJZ9dPY2s+mDvhDm1u2BGQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-remap-async-to-generator": "^7.16.8", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz", - "integrity": "sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-class-static-block": { - "version": "7.17.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz", - "integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.17.6", - "@babel/helper-plugin-utils": "^7.16.7", - "@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-decorators": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.17.9.tgz", - "integrity": "sha512-EfH2LZ/vPa2wuPwJ26j+kYRkaubf89UlwxKXtxqEm57HrgSEYDB8t4swFP+p8LcI9yiP9ZRJJjo/58hS6BnaDA==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.17.9", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/plugin-syntax-decorators": "^7.17.0", - "charcodes": "^0.2.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-dynamic-import": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz", - "integrity": "sha512-I8SW9Ho3/8DRSdmDdH3gORdyUuYnk1m4cMxUAdu5oy4n3OfN8flDEH+d60iG7dUfi0KkYwSvoalHzzdRzpWHTg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.16.7.tgz", - "integrity": "sha512-ZxdtqDXLRGBL64ocZcs7ovt71L3jhC1RGSyR996svrCi3PYqHNkb3SwPJCs8RIzD86s+WPpt2S73+EHCGO+NUA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.16.7.tgz", - "integrity": "sha512-lNZ3EEggsGY78JavgbHsK9u5P3pQaW7k4axlgFLYkMd7UBsiNahCITShLjNQschPyjtO6dADrL24757IdhBrsQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.16.7.tgz", - "integrity": "sha512-K3XzyZJGQCr00+EtYtrDjmwX7o7PLK6U9bi1nCwkQioRFVUv6dJoxbQjtWVtP+bCPy82bONBKG8NPyQ4+i6yjg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.16.7.tgz", - "integrity": "sha512-aUOrYU3EVtjf62jQrCj63pYZ7k6vns2h/DQvHPWGmsJRYzWXZ6/AsfgpiRy6XiuIDADhJzP2Q9MwSMKauBQ+UQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.16.7.tgz", - "integrity": "sha512-vQgPMknOIgiuVqbokToyXbkY/OmmjAzr/0lhSIbG/KmnzXPGwW/AdhdKpi+O4X/VkWiWjnkKOBiqJrTaC98VKw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.17.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.17.3.tgz", - "integrity": "sha512-yuL5iQA/TbZn+RGAfxQXfi7CNLmKi1f8zInn4IgobuCWcAb7i+zj4TYzQ9l8cEzVyJ89PDGuqxK1xZpUDISesw==", - "dependencies": { - "@babel/compat-data": "^7.17.0", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-optional-catch-binding": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", - "integrity": "sha512-eMOH/L4OvWSZAE1VkHbr1vckLG1WUcHGJSLqqQwl2GaUqG6QjddvrOaTUMNYiv77H5IKPMZ9U9P7EaHwvAShfA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@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.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.16.7.tgz", - "integrity": "sha512-eC3xy+ZrUcBtP7x+sq62Q/HYd674pPTb/77XZMb5wbDPGWIdUbSr4Agr052+zaUPSb+gGRnjxXfKFvx5iMJ+DA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.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.16.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.16.11.tgz", - "integrity": "sha512-F/2uAkPlXDr8+BHpZvo19w3hLFKge+k75XUprE6jaqKxjGkSYcK+4c+bup5PdW/7W/Rpjwql7FTVEDW+fRAQsw==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.16.10", - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.16.7.tgz", - "integrity": "sha512-rMQkjcOFbm+ufe3bTZLyOfsOUOxyvLXZJCTARhJr+8UMSoZmqTe1K1BgkFcrW37rAchWg57yI69ORxiWvUINuQ==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" - }, + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, "engines": { "node": ">=6.9.0" }, @@ -744,36 +524,11 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-unicode-property-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.16.7.tgz", - "integrity": "sha512-QRK0YI/40VLhNVGIjRNAAQkEHws0cswSdFFjpFyt943YmJIU1da9uW63Iu6NFV6CxTZW5eTDCrwZUstBWgp/Rg==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - }, - "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==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -785,6 +540,7 @@ "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" }, @@ -796,6 +552,7 @@ "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" }, @@ -806,24 +563,11 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.0.tgz", - "integrity": "sha512-qWe85yCXsvDEluNP0OyeQjH63DlhAR3W7K9BxxU1MvbDb48tgBG+Ao6IJJ6smPDrrVzSQZrbF6donpkFBMcs3A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" - }, - "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" }, @@ -835,6 +579,7 @@ "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" }, @@ -842,12 +587,28 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.16.7.tgz", - "integrity": "sha512-UDo3YGQO0jH6ytzVwgSLv9i/CzMcUjbKenL67dTrAZPPv6GFAtDhe6jqnvmoKzC/7htNTohhos+onPtDMqJwaQ==", + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", + "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", + "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -860,6 +621,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -871,6 +633,7 @@ "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" }, @@ -878,24 +641,11 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", - "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.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" }, @@ -907,6 +657,7 @@ "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" }, @@ -918,6 +669,7 @@ "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" }, @@ -929,6 +681,7 @@ "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" }, @@ -940,6 +693,7 @@ "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" }, @@ -951,6 +705,7 @@ "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" }, @@ -962,6 +717,7 @@ "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" }, @@ -976,6 +732,7 @@ "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" }, @@ -986,12 +743,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz", - "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==", + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@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" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", + "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1000,12 +774,16 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz", - "integrity": "sha512-9ffkFFMbvzTvv+7dTp/66xvZAWASuPD5Tl9LK3Z9vhOmANo6j94rik+5YMBt4CwHVMWLWpMsriIc2zsa3WW3xQ==", + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.2.tgz", + "integrity": "sha512-BBYVGxbDVHfoeXbOwcagAkOQAm9NxoTdMGfTqghu1GrvadSaw6iW3Je6IcL5PNOw8VwjxqBECXy50/iCQSY/lQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/plugin-syntax-async-generators": "^7.8.4" }, "engines": { "node": ">=6.9.0" @@ -1015,13 +793,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.16.8.tgz", - "integrity": "sha512-MtmUmTJQHCnyJVrScNzNlofQJ3dLFuobYn3mwOTKHnSCMtbNsqvF71GQmJfFjdrXSsAA7iysFmYWw4bXZ20hOg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", + "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", + "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-remap-async-to-generator": "^7.16.8" + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1031,11 +810,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.16.7.tgz", - "integrity": "sha512-JUuzlzmF40Z9cXyytcbZEZKckgrQzChbQJw/5PuEHYeqzCsvebDx0K0jWnIIVcmmDOAVctCgnYs0pMcrYj2zJg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", + "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1045,11 +825,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.16.7.tgz", - "integrity": "sha512-ObZev2nxVAYA4bhyusELdo9hb3H+A56bxH3FZMbEImZFiEDYVHXQSJ1hQKFlDnlt8G9bBrCZ5ZpURZUrV4G5qQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.0.tgz", + "integrity": "sha512-cOsrbmIOXmf+5YbL99/S49Y3j46k/T16b9ml8bm9lP6N9US5iQ2yBK7gpui1pg0V/WMcXdkfKbTb7HXq9u+v4g==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1058,18 +839,53 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.16.7.tgz", - "integrity": "sha512-WY7og38SFAGYRe64BrjKf8OrE6ulEHtr5jEYaZMwox9KebgqPi67Zqz8K53EKk1fFEJgm96r32rkKZ3qA2nCWQ==", + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", + "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz", + "integrity": "sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.11", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz", + "integrity": "sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.9", + "@babel/helper-split-export-declaration": "^7.22.6", "globals": "^11.1.0" }, "engines": { @@ -1080,11 +896,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.16.7.tgz", - "integrity": "sha512-gN72G9bcmenVILj//sv1zLNaPyYcOzUho2lIJBMh/iakJ9ygCo/hEF9cpGb61SCMEDxbbyBoVQxrt+bWKu5KGw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", + "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/template": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1094,11 +912,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.17.7.tgz", - "integrity": "sha512-XVh0r5yq9sLR4vZ6eVZe8FKfIcSgaTBxVBRSYokRj2qksf6QerYnTxz9/GTuKTH/n/HwLP7t6gtlybHetJ/6hQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.0.tgz", + "integrity": "sha512-vaMdgNXFkYrB+8lbgniSYWHsgqK5gjaMNcc84bMIOMRLH0L9AqYq3hwMdvnyqj1OPqea8UtjPEuS/DCenah1wg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1108,12 +927,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.16.7.tgz", - "integrity": "sha512-Lyttaao2SjZF6Pf4vk1dVKv8YypMpomAbygW+mU5cYP3S5cWTfCJjG8xV6CFdzGFlfWK81IjL9viiTvpb6G7gQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", + "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1123,11 +943,28 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.16.7.tgz", - "integrity": "sha512-03DvpbRfvWIXyK0/6QiR1KMTWeT6OcQ7tbhjrXyFS02kjuX/mu5Bvnh5SDSWHxyawit2g5aWhKwI86EE7GUnTw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", + "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz", + "integrity": "sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" }, "engines": { "node": ">=6.9.0" @@ -1137,12 +974,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.16.7.tgz", - "integrity": "sha512-8UYLSlyLgRixQvlYH3J2ekXFHDFLQutdy7FfFAMm3CPZ6q9wHCwnUyiXpQCe3gVVnQlHc5nsuiEVziteRNTXEA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", + "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", + "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1151,13 +989,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.16.7.tgz", - "integrity": "sha512-mzmCq3cNsDpZZu9FADYYyfZJIOrSONmHcop2XEKPdBNMa4PDC4eEvcOvzZaCNcjKu72v0XQlA5y1g58aLRXdYg==", + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz", + "integrity": "sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-flow": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "engines": { "node": ">=6.9.0" @@ -1167,11 +1006,12 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.7.tgz", - "integrity": "sha512-/QZm9W92Ptpw7sjI9Nx1mbcsWz33+l8kuMIQnDwgQBG5s3fAfQvkRjQ7NqXhtNcKOnPkdICmUHyCaWW06HCsqg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz", + "integrity": "sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1181,13 +1021,30 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.16.7.tgz", - "integrity": "sha512-SU/C68YVwTRxqWj5kgsbKINakGag0KTgq9f2iZEXdStoAbOzLHEBRYzImmA6yFo8YZhJVflvXmIHUO7GWHmxxA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", + "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", + "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-compilation-targets": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz", + "integrity": "sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-json-strings": "^7.8.3" }, "engines": { "node": ">=6.9.0" @@ -1197,11 +1054,28 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.16.7.tgz", - "integrity": "sha512-6tH8RTpTWI0s2sV6uq3e/C9wPo4PTqqZps4uF0kzQ9/xPLFQtipynvmT1g/dOfEJ+0EQsHhkQ/zyRId8J2b8zQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", + "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz", + "integrity": "sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" }, "engines": { "node": ">=6.9.0" @@ -1211,11 +1085,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.16.7.tgz", - "integrity": "sha512-mBruRMbktKQwbxaJof32LT9KLy2f3gH+27a5XSuXo6h7R3vqltl0PgZ80C8ZMKw98Bf8bqt6BEVi3svOh2PzMw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", + "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1225,13 +1100,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz", - "integrity": "sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.0.tgz", + "integrity": "sha512-xWT5gefv2HGSm4QHtgc1sYPbseOyf+FFDo2JbpE25GWl5BqTGO9IMwTYJRoIdjsF85GE+VegHxSCUt5EvoYTAw==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "babel-plugin-dynamic-import-node": "^2.3.3" + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1241,14 +1116,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.17.9.tgz", - "integrity": "sha512-2TBFd/r2I6VlYn0YRTz2JdazS+FoUuQ2rIFHoAxtyP/0G3D82SBLaRq9rnUkpqlLg03Byfl/+M32mpxjO6KaPw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.0.tgz", + "integrity": "sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-simple-access": "^7.17.7", - "babel-plugin-dynamic-import-node": "^2.3.3" + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1258,15 +1133,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.17.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.17.8.tgz", - "integrity": "sha512-39reIkMTUVagzgA5x88zDYXPCMT6lcaRKs1+S9K6NKBPErbgO/w/kP8GlNQTC87b412ZTlmNgr3k2JrWgHH+Bw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.0.tgz", + "integrity": "sha512-qBej6ctXZD2f+DhlOC9yO47yEYgUh5CZNz/aBoH4j/3NOlRfJXJbY7xDQCqQVf9KbrqGzIWER1f23doHGrIHFg==", + "dev": true, "dependencies": { - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-identifier": "^7.16.7", - "babel-plugin-dynamic-import-node": "^2.3.3" + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -1276,12 +1151,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.16.7.tgz", - "integrity": "sha512-EMh7uolsC8O4xhudF2F6wedbSHm1HHZ0C6aJ7K67zcDNidMzVcxWdGr+htW9n21klm+bOn+Rx4CBsAntZd3rEQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", + "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1291,11 +1167,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.16.8.tgz", - "integrity": "sha512-j3Jw+n5PvpmhRR+mrgIh04puSANCk/T/UA3m3P1MjJkhlK906+ApHhDIqBQDdOgL/r1UYpz4GNclTXxyZrYGSw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7" + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1305,11 +1183,63 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.16.7.tgz", - "integrity": "sha512-xiLDzWNMfKoGOpc6t3U+etCE2yRnn3SM09BXqWPIZOBpL2gvVrBWUKnsJx0K/ADi5F5YC5f8APFfWrz25TdlGg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", + "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz", + "integrity": "sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@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-transform-numeric-separator": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz", + "integrity": "sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz", + "integrity": "sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -1319,12 +1249,46 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.16.7.tgz", - "integrity": "sha512-14J1feiQVWaGvRxj2WjyMuXS2jsBkgB3MdSN5HuC2G5nRspa5RK9COcs82Pwy5BuGcjb+fYaUj94mYcOj7rCvw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", + "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz", + "integrity": "sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@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-transform-optional-chaining": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.0.tgz", + "integrity": "sha512-sBBGXbLJjxTzLBF5rFWaikMnOGOk/BmK6vVByIdEggZ7Vn6CvWXZyRkkLFK6WE0IF8jSliyOkUN6SScFgzCM0g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "engines": { "node": ">=6.9.0" @@ -1334,11 +1298,46 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.16.7.tgz", - "integrity": "sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz", + "integrity": "sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", + "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz", + "integrity": "sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.11", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -1348,11 +1347,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.16.7.tgz", - "integrity": "sha512-z4FGr9NMGdoIl1RqavCqGG+ZuYjfZ/hkCIeuH6Do7tXmSm0ls11nYVSJqFEUOSJbDab5wC6lRE/w6YjVcr6Hqw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", + "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1361,12 +1361,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.17.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.17.6.tgz", - "integrity": "sha512-OBv9VkyyKtsHZiHLoSfCn+h6yU7YKX8nrs32xUmOa1SRSk+t03FosB6fBZ0Yz4BpD1WV7l73Nsad+2Tz7APpqw==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.22.5.tgz", + "integrity": "sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1375,59 +1376,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz", - "integrity": "sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz", + "integrity": "sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.17.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", - "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-jsx": "^7.16.7", - "@babel/types": "^7.17.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", - "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz", - "integrity": "sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1437,11 +1392,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.17.9.tgz", - "integrity": "sha512-Lc2TfbxR1HOyn/c6b4Y/b6NHoTb67n/IoWLxTu4kC7h4KQnWlhCq2S8Tx0t2SVvv5Uu87Hs+6JEJ5kt2tYGylQ==", + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", + "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==", + "dev": true, "dependencies": { - "regenerator-transform": "^0.15.0" + "@babel/helper-plugin-utils": "^7.22.5", + "regenerator-transform": "^0.15.2" }, "engines": { "node": ">=6.9.0" @@ -1451,30 +1408,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.16.7.tgz", - "integrity": "sha512-KQzzDnZ9hWQBjwi5lpY5v9shmm6IVG0U9pB18zvMu2i4H90xpT4gmqwPYsn8rObiadYe2M0gmgsiOIF5A/2rtg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", + "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.0.tgz", - "integrity": "sha512-fr7zPWnKXNc1xoHfrIU9mN/4XKX4VLZ45Q+oMhfsYIaHvg7mHgmhfOy/ckRWqDK7XF3QDigRpkh5DKq6+clE8A==", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "babel-plugin-polyfill-corejs2": "^0.3.0", - "babel-plugin-polyfill-corejs3": "^0.5.0", - "babel-plugin-polyfill-regenerator": "^0.3.0", - "semver": "^6.3.0" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1484,11 +1423,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.16.7.tgz", - "integrity": "sha512-hah2+FEnoRoATdIb05IOXf+4GzXYTq75TVhIn1PewihbpyrNWUt2JbudKQOETWw6QpLe+AIUpJ5MVLYTQbeeUg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", + "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1498,12 +1438,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.16.7.tgz", - "integrity": "sha512-+pjJpgAngb53L0iaA5gU/1MLXJIfXcYepLgXB3esVRf4fqmj8f2cxM3/FKaHsZms08hFQJkFccEWuIpm429TXg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", + "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1513,11 +1454,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.16.7.tgz", - "integrity": "sha512-NJa0Bd/87QV5NZZzTuZG5BPJjLYadeSZ9fO6oOUoL4iQx+9EEuw/eEM92SrsT19Yc2jgB1u1hsjqDtH02c3Drw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", + "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1527,11 +1469,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.16.7.tgz", - "integrity": "sha512-VwbkDDUeenlIjmfNeDX/V0aWrQH2QiVyJtwymVQSzItFDTpxfyJh3EVaQiS0rIN/CqbLGr0VcGmuwyTdZtdIsA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", + "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1541,27 +1484,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.16.7.tgz", - "integrity": "sha512-p2rOixCKRJzpg9JB4gjnG4gjWkWa89ZoYUnl9snJ1cWIcTH/hvxZqfO+WjG6T8DRBpctEol5jw1O5rA8gkCokQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", + "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", - "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-typescript": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1571,11 +1499,28 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", - "integrity": "sha512-TAV5IGahIz3yZ9/Hfv35TV2xEm+kaBDaZQCn2S/hG9/CZ0DktxJv9eKfPc7yYCvOYR4JGx1h8C+jcSOvgaaI/Q==", + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", + "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", + "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1585,12 +1530,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.16.7.tgz", - "integrity": "sha512-oC5tYYKw56HO75KZVLQ+R/Nl3Hro9kf8iG0hXoaHP7tjAyCpvqBiSNe6vGrZni1Z6MggmUOC6A7VP7AVmw225Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", + "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1599,37 +1545,43 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env": { - "version": "7.16.11", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.16.11.tgz", - "integrity": "sha512-qcmWG8R7ZW6WBRPZK//y+E3Cli151B20W1Rv7ln27vuPaXU/8TKms6jFdiJtF7UDTxcrb7mZd88tAeK9LjdT8g==", + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", + "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", + "dev": true, "dependencies": { - "@babel/compat-data": "^7.16.8", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.16.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.16.7", - "@babel/plugin-proposal-async-generator-functions": "^7.16.8", - "@babel/plugin-proposal-class-properties": "^7.16.7", - "@babel/plugin-proposal-class-static-block": "^7.16.7", - "@babel/plugin-proposal-dynamic-import": "^7.16.7", - "@babel/plugin-proposal-export-namespace-from": "^7.16.7", - "@babel/plugin-proposal-json-strings": "^7.16.7", - "@babel/plugin-proposal-logical-assignment-operators": "^7.16.7", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", - "@babel/plugin-proposal-numeric-separator": "^7.16.7", - "@babel/plugin-proposal-object-rest-spread": "^7.16.7", - "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", - "@babel/plugin-proposal-optional-chaining": "^7.16.7", - "@babel/plugin-proposal-private-methods": "^7.16.11", - "@babel/plugin-proposal-private-property-in-object": "^7.16.7", - "@babel/plugin-proposal-unicode-property-regex": "^7.16.7", + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.2.tgz", + "integrity": "sha512-BW3gsuDD+rvHL2VO2SjAUNTBe5YrjsTiDyqamPDWY723na3/yPQ65X5oQkFVJZ0o50/2d+svm1rkPoJeR1KxVQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.2", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.15", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.15", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@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.22.5", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", "@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", @@ -1639,45 +1591,62 @@ "@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.16.7", - "@babel/plugin-transform-async-to-generator": "^7.16.8", - "@babel/plugin-transform-block-scoped-functions": "^7.16.7", - "@babel/plugin-transform-block-scoping": "^7.16.7", - "@babel/plugin-transform-classes": "^7.16.7", - "@babel/plugin-transform-computed-properties": "^7.16.7", - "@babel/plugin-transform-destructuring": "^7.16.7", - "@babel/plugin-transform-dotall-regex": "^7.16.7", - "@babel/plugin-transform-duplicate-keys": "^7.16.7", - "@babel/plugin-transform-exponentiation-operator": "^7.16.7", - "@babel/plugin-transform-for-of": "^7.16.7", - "@babel/plugin-transform-function-name": "^7.16.7", - "@babel/plugin-transform-literals": "^7.16.7", - "@babel/plugin-transform-member-expression-literals": "^7.16.7", - "@babel/plugin-transform-modules-amd": "^7.16.7", - "@babel/plugin-transform-modules-commonjs": "^7.16.8", - "@babel/plugin-transform-modules-systemjs": "^7.16.7", - "@babel/plugin-transform-modules-umd": "^7.16.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.16.8", - "@babel/plugin-transform-new-target": "^7.16.7", - "@babel/plugin-transform-object-super": "^7.16.7", - "@babel/plugin-transform-parameters": "^7.16.7", - "@babel/plugin-transform-property-literals": "^7.16.7", - "@babel/plugin-transform-regenerator": "^7.16.7", - "@babel/plugin-transform-reserved-words": "^7.16.7", - "@babel/plugin-transform-shorthand-properties": "^7.16.7", - "@babel/plugin-transform-spread": "^7.16.7", - "@babel/plugin-transform-sticky-regex": "^7.16.7", - "@babel/plugin-transform-template-literals": "^7.16.7", - "@babel/plugin-transform-typeof-symbol": "^7.16.7", - "@babel/plugin-transform-unicode-escapes": "^7.16.7", - "@babel/plugin-transform-unicode-regex": "^7.16.7", - "@babel/preset-modules": "^0.1.5", - "@babel/types": "^7.16.8", - "babel-plugin-polyfill-corejs2": "^0.3.0", - "babel-plugin-polyfill-corejs3": "^0.5.0", - "babel-plugin-polyfill-regenerator": "^0.3.0", - "core-js-compat": "^3.20.2", - "semver": "^6.3.0" + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.22.5", + "@babel/plugin-transform-async-generator-functions": "^7.23.2", + "@babel/plugin-transform-async-to-generator": "^7.22.5", + "@babel/plugin-transform-block-scoped-functions": "^7.22.5", + "@babel/plugin-transform-block-scoping": "^7.23.0", + "@babel/plugin-transform-class-properties": "^7.22.5", + "@babel/plugin-transform-class-static-block": "^7.22.11", + "@babel/plugin-transform-classes": "^7.22.15", + "@babel/plugin-transform-computed-properties": "^7.22.5", + "@babel/plugin-transform-destructuring": "^7.23.0", + "@babel/plugin-transform-dotall-regex": "^7.22.5", + "@babel/plugin-transform-duplicate-keys": "^7.22.5", + "@babel/plugin-transform-dynamic-import": "^7.22.11", + "@babel/plugin-transform-exponentiation-operator": "^7.22.5", + "@babel/plugin-transform-export-namespace-from": "^7.22.11", + "@babel/plugin-transform-for-of": "^7.22.15", + "@babel/plugin-transform-function-name": "^7.22.5", + "@babel/plugin-transform-json-strings": "^7.22.11", + "@babel/plugin-transform-literals": "^7.22.5", + "@babel/plugin-transform-logical-assignment-operators": "^7.22.11", + "@babel/plugin-transform-member-expression-literals": "^7.22.5", + "@babel/plugin-transform-modules-amd": "^7.23.0", + "@babel/plugin-transform-modules-commonjs": "^7.23.0", + "@babel/plugin-transform-modules-systemjs": "^7.23.0", + "@babel/plugin-transform-modules-umd": "^7.22.5", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.22.5", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", + "@babel/plugin-transform-numeric-separator": "^7.22.11", + "@babel/plugin-transform-object-rest-spread": "^7.22.15", + "@babel/plugin-transform-object-super": "^7.22.5", + "@babel/plugin-transform-optional-catch-binding": "^7.22.11", + "@babel/plugin-transform-optional-chaining": "^7.23.0", + "@babel/plugin-transform-parameters": "^7.22.15", + "@babel/plugin-transform-private-methods": "^7.22.5", + "@babel/plugin-transform-private-property-in-object": "^7.22.11", + "@babel/plugin-transform-property-literals": "^7.22.5", + "@babel/plugin-transform-regenerator": "^7.22.10", + "@babel/plugin-transform-reserved-words": "^7.22.5", + "@babel/plugin-transform-shorthand-properties": "^7.22.5", + "@babel/plugin-transform-spread": "^7.22.5", + "@babel/plugin-transform-sticky-regex": "^7.22.5", + "@babel/plugin-transform-template-literals": "^7.22.5", + "@babel/plugin-transform-typeof-symbol": "^7.22.5", + "@babel/plugin-transform-unicode-escapes": "^7.22.10", + "@babel/plugin-transform-unicode-property-regex": "^7.22.5", + "@babel/plugin-transform-unicode-regex": "^7.22.5", + "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "@babel/types": "^7.23.0", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -1687,104 +1656,64 @@ } }, "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==", + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "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" + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@babel/preset-react": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.7.tgz", - "integrity": "sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-transform-react-display-name": "^7.16.7", - "@babel/plugin-transform-react-jsx": "^7.16.7", - "@babel/plugin-transform-react-jsx-development": "^7.16.7", - "@babel/plugin-transform-react-pure-annotations": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", - "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-transform-typescript": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "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.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", + "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", "dependencies": { - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz", - "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==", - "dependencies": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.9.tgz", - "integrity": "sha512-PQO8sDIJ8SIwipTPiR71kJQCKQYB5NGImbOviK8K+kg5xkNSYXLBupuX9QhatFowrsvo9Hj8WgArg3W7ijNAQw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.9", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.17.9", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.9", - "@babel/types": "^7.17.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1793,421 +1722,565 @@ } }, "node_modules/@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" - }, - "node_modules/@csstools/normalize.css": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", - "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==" - }, - "node_modules/@csstools/postcss-color-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.0.tgz", - "integrity": "sha512-5D5ND/mZWcQoSfYnSPsXtuiFxhzmhxt6pcjrFLJyldj+p0ZN2vvRpYNX+lahFTtMhAYOa2WmkdGINr0yP0CvGA==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-font-format-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.0.tgz", - "integrity": "sha512-oO0cZt8do8FdVBX8INftvIA4lUrKUSCcWUf9IwH9IPWOgKT22oAZFXeHLoDK7nhB2SmkNycp5brxfNMRLIhd6Q==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/@csstools/postcss-hwb-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.0.tgz", - "integrity": "sha512-VSTd7hGjmde4rTj1rR30sokY3ONJph1reCBTUXqeW1fKwETPy1x4t/XIeaaqbMbC5Xg4SM/lyXZ2S8NELT2TaA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/@csstools/postcss-ic-unit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.0.tgz", - "integrity": "sha512-i4yps1mBp2ijrx7E96RXrQXQQHm6F4ym1TOD0D69/sjDjZvQ22tqiEvaNw7pFZTUO5b9vWRHzbHzP9+UKuw+bA==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.2.tgz", - "integrity": "sha512-L9h1yxXMj7KpgNzlMrw3isvHJYkikZgZE4ASwssTnGEH8tm50L6QsM9QQT5wR4/eO5mU0rN5axH7UzNxEYg5CA==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-normalize-display-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.0.tgz", - "integrity": "sha512-bX+nx5V8XTJEmGtpWTO6kywdS725t71YSLlxWt78XoHUbELWgoCXeOFymRJmL3SU1TLlKSIi7v52EWqe60vJTQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/@csstools/postcss-oklab-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.0.tgz", - "integrity": "sha512-e/Q5HopQzmnQgqimG9v3w2IG4VRABsBq3itOcn4bnm+j4enTgQZ0nWsaH/m9GV2otWGQ0nwccYL5vmLKyvP1ww==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", - "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@electron/get": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.14.1.tgz", - "integrity": "sha512-BrZYyL/6m0ZXz/lDxy/nlVhQz+WF+iPS6qXolEU8atw7h6v1aYkjwJZ63m+bJMBTxDE66X+r2tPS4a/8C82sZw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^9.6.0", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=8.6" - }, - "optionalDependencies": { - "global-agent": "^3.0.0", - "global-tunnel-ng": "^2.7.1" - } - }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@electron/universal": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.2.0.tgz", - "integrity": "sha512-eu20BwNsrMPKoe2bZ3/l9c78LclDvxg3PlVXrQf3L50NaUuW5M59gbPytI+V4z7/QMrohUHetQaU0ou+p1UG9Q==", - "dev": true, - "dependencies": { - "@malept/cross-spawn-promise": "^1.1.0", - "asar": "^3.1.0", - "debug": "^4.3.1", - "dir-compare": "^2.4.0", - "fs-extra": "^9.0.1", - "minimatch": "^3.0.4", - "plist": "^3.0.4" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@emotion/babel-plugin": { - "version": "11.9.2", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", - "integrity": "sha512-Pr/7HGH6H6yKgnVFNEj2MVlreu3ADqftqjqwUvDy/OJzKFgxKeTQ+eeUf20FOTuHVkDON2iNa25rAXVYtWJCjw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", "dependencies": { - "@babel/helper-module-imports": "^7.12.13", - "@babel/plugin-syntax-jsx": "^7.12.13", - "@babel/runtime": "^7.13.10", - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.5", - "@emotion/serialize": "^1.0.2", - "babel-plugin-macros": "^2.6.1", + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", - "stylis": "4.0.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "stylis": "4.2.0" } }, + "node_modules/@emotion/babel-plugin/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/@emotion/cache": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.7.1.tgz", - "integrity": "sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", "dependencies": { - "@emotion/memoize": "^0.7.4", - "@emotion/sheet": "^1.1.0", - "@emotion/utils": "^1.0.0", - "@emotion/weak-memoize": "^0.2.5", - "stylis": "4.0.13" + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" } }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.1.2.tgz", - "integrity": "sha512-3QnhqeL+WW88YjYbQL5gUIkthuMw7a0NGbZ7wfFVk2kg/CK5w8w5FFa0RzWjyY1+sujN0NWbtSHH6OJmWHtJpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", "dependencies": { - "@emotion/memoize": "^0.7.4" + "@emotion/memoize": "^0.8.1" } }, "node_modules/@emotion/memoize": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz", - "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.0.tgz", - "integrity": "sha512-lBVSF5d0ceKtfKCDQJveNAtkC7ayxpVlgOohLgXqRwqWr9bOf4TZAFFyIcNngnV6xK6X4x2ZeXq7vliHkoVkxQ==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", + "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", "dependencies": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/cache": "^11.7.1", - "@emotion/serialize": "^1.0.3", - "@emotion/utils": "^1.1.0", - "@emotion/weak-memoize": "^0.2.5", + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { - "@babel/core": "^7.0.0", "react": ">=16.8.0" }, "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, "@types/react": { "optional": true } } }, "node_modules/@emotion/serialize": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-2mSSvgLfyV3q+iVh3YWgNlUc2a9ZlDU7DjuP5MjK3AXRR0dYigCrP99aeFtaB2L/hjfEZdSThn5dsZ0ufqbvsA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", + "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", "dependencies": { - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.4", - "@emotion/unitless": "^0.7.5", - "@emotion/utils": "^1.0.0", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.0.tgz", - "integrity": "sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "node_modules/@emotion/styled": { - "version": "11.8.1", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz", - "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", + "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", "dependencies": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/is-prop-valid": "^1.1.2", - "@emotion/serialize": "^1.0.2", - "@emotion/utils": "^1.1.0" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" }, "peerDependencies": { - "@babel/core": "^7.0.0", "@emotion/react": "^11.0.0-rc.0", "react": ">=16.8.0" }, "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, "@types/react": { "optional": true } } }, "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } }, "node_modules/@emotion/utils": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz", - "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", - "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "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.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } }, "node_modules/@eslint/eslintrc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.2.tgz", - "integrity": "sha512-lTVWHs7O2hjBFZunXTZYnYqtB9GakA1lnxIf+gKq2nY5gxkkNi/lQvveW6t8gFdOHTg6nG50Xs95PrLqVpcaLg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", + "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", + "espree": "^9.6.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", + "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/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -2218,21 +2291,11 @@ "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==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "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" }, @@ -2240,791 +2303,163 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "node_modules/@eslint/js": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", + "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.2.tgz", + "integrity": "sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.1", "debug": "^4.1.1", - "minimatch": "^3.0.4" + "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==" - }, - "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==", - "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/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@istanbuljs/load-nyc-config/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==", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/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==", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/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==", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/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==", - "dependencies": { - "p-limit": "^2.2.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==", - "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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/console/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/@jest/console/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/@jest/console/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==", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "dev": true + }, + "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==", + "dev": true, "dependencies": { - "color-name": "~1.1.4" + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/console/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/@jest/console/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/core": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", - "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/reporters": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^27.5.1", - "jest-config": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-resolve-dependencies": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "jest-watcher": "^27.5.1", - "micromatch": "^4.0.4", - "rimraf": "^3.0.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/core/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/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/@jest/core/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/@jest/core/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/@jest/core/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/core/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/environment": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", - "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", - "dependencies": { - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", - "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", - "dependencies": { - "@jest/types": "^27.5.1", - "@sinonjs/fake-timers": "^8.0.1", - "@types/node": "*", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", - "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/types": "^27.5.1", - "expect": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", - "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.2", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-haste-map": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "slash": "^3.0.0", - "source-map": "^0.6.0", - "string-length": "^4.0.1", - "terminal-link": "^2.0.0", - "v8-to-istanbul": "^8.1.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/reporters/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/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/@jest/reporters/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/@jest/reporters/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/@jest/reporters/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/reporters/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@jest/reporters/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/source-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", - "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", - "dependencies": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9", - "source-map": "^0.6.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/source-map/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@jest/test-result": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", - "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", - "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", - "dependencies": { - "@jest/test-result": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-runtime": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", - "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", - "dependencies": { - "@babel/core": "^7.1.0", - "@jest/types": "^27.5.1", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-util": "^27.5.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/transform/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/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/@jest/transform/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/@jest/transform/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/@jest/transform/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@jest/transform/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", - "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^16.0.0", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/types/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/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/@jest/types/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/@jest/types/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/@jest/types/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", - "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", - "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "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==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz", - "integrity": "sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==" - }, - "node_modules/@malept/cross-spawn-promise": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", - "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } - ], - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "dev": true, "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, - "engines": { - "node": ">= 10.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, + "node_modules/@mapbox/hast-util-table-cell-style": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mapbox/hast-util-table-cell-style/-/hast-util-table-cell-style-0.2.1.tgz", + "integrity": "sha512-LyQz4XJIdCdY/+temIhD/Ed0x/p4GAOUycpFSEK2Ads1CPKZy6b7V/2ROEtQiLLQ8soIs0xe/QAoR6kwpyW/yw==", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "unist-util-visit": "^1.4.1" }, "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/@mui/base": { - "version": "5.0.0-alpha.77", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.77.tgz", - "integrity": "sha512-Zqm3qlczGViD3lJSYo8ZnQLHJ3PwGYftbDfVuh2Rq5OD88F7H6oDILlqknzty59NDkeSVO2qlymYmHOY1nLodg==", + "version": "5.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.22.tgz", + "integrity": "sha512-l4asGID5tmyerx9emJfXOKLyXzaBtdXNIFE3M+IrSZaFtGFvaQKHhc3+nxxSxPf1+G44psjczM0ekRQCdXx9HA==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@emotion/is-prop-valid": "^1.1.2", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "@popperjs/core": "^2.11.5", - "clsx": "^1.1.1", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" + "@babel/runtime": "^7.23.2", + "@floating-ui/react-dom": "^2.0.2", + "@mui/types": "^7.2.8", + "@mui/utils": "^5.14.16", + "@popperjs/core": "^2.11.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1" }, "engines": { "node": ">=12.0.0" @@ -3044,12 +2479,21 @@ } } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.16.tgz", + "integrity": "sha512-97isBjzH2v1K7oB4UH2f4NOkBShOynY6dhnoR2XlUk/g6bb7ZBv2I3D1hvvqPtpEigKu93e7f/jAYr5d9LOc5w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + } + }, "node_modules/@mui/icons-material": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.6.2.tgz", - "integrity": "sha512-9QdI7axKuBAyaGz4mtdi7Uy1j73/thqFmEuxpJHxNC7O8ADEK1Da3t2veK2tgmsXsUlAHcAG63gg+GvWWeQNqQ==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.14.16.tgz", + "integrity": "sha512-wmOgslMEGvbHZjFLru8uH5E+pif/ciXAvKNw16q6joK6EWVWU5rDYWFknDaZhCvz8ZE/K8ZnJQ+lMG6GgHzXbg==", "dependencies": { - "@babel/runtime": "^7.17.2" + "@babel/runtime": "^7.23.2" }, "engines": { "node": ">=12.0.0" @@ -3070,22 +2514,22 @@ } }, "node_modules/@mui/material": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.6.2.tgz", - "integrity": "sha512-bwMvroBrMgUTwUh/BcjhtcJwEw9uH4chV3+ZSj6RckOJtMj8U4yEeD7S4NgHE8Ioj5eObKFzHpih/cTD1sDRpg==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.14.16.tgz", + "integrity": "sha512-W4zZ4vnxgGk6/HqBwgsDHKU7x2l2NhX+r8gAwfg58Rhu3ikfY7NkIS6y8Gl3NkATc4GG1FNaGjjpQKfJx3U6Jw==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@mui/base": "5.0.0-alpha.77", - "@mui/system": "^5.6.2", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "@types/react-transition-group": "^4.4.4", - "clsx": "^1.1.1", - "csstype": "^3.0.11", - "hoist-non-react-statics": "^3.3.2", - "prop-types": "^15.7.2", - "react-is": "^17.0.2", - "react-transition-group": "^4.4.2" + "@babel/runtime": "^7.23.2", + "@mui/base": "5.0.0-beta.22", + "@mui/core-downloads-tracker": "^5.14.16", + "@mui/system": "^5.14.16", + "@mui/types": "^7.2.8", + "@mui/utils": "^5.14.16", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1", + "react-is": "^18.2.0", + "react-transition-group": "^4.4.5" }, "engines": { "node": ">=12.0.0" @@ -3114,13 +2558,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.6.2.tgz", - "integrity": "sha512-IbrSfFXfiZdyhRMC2bgGTFtb16RBQ5mccmjeh3MtAERWuepiCK7gkW5D9WhEsfTu6iez+TEjeUKSgmMHlsM2mg==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.16.tgz", + "integrity": "sha512-FNlL0pTSEBh8nXsVWreCHDSHk+jG8cBx1sxRbT8JVtL+PYbYPi802zfV4B00Kkf0LNRVRvAVQwojMWSR/MYGng==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@mui/utils": "^5.6.1", - "prop-types": "^15.7.2" + "@babel/runtime": "^7.23.2", + "@mui/utils": "^5.14.16", + "prop-types": "^15.8.1" }, "engines": { "node": ">=12.0.0" @@ -3140,13 +2584,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.6.1.tgz", - "integrity": "sha512-jEhH6TBY8jc9S8yVncXmoTYTbATjEu44RMFXj6sIYfKr5NArVwTwRo3JexLL0t3BOAiYM4xsFLgfKEIvB9SAeQ==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.16.tgz", + "integrity": "sha512-FfvYvTG/Zd+KXMMImbcMYEeQAbONGuX5Vx3gBmmtB6KyA7Mvm9Pma1ly3R0gc44yeoFd+2wBjn1feS8h42HW5w==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@emotion/cache": "^11.7.1", - "prop-types": "^15.7.2" + "@babel/runtime": "^7.23.2", + "@emotion/cache": "^11.11.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1" }, "engines": { "node": ">=12.0.0" @@ -3170,18 +2615,18 @@ } }, "node_modules/@mui/system": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.6.2.tgz", - "integrity": "sha512-Wg9TRbvavSwEYk6UdpnoDx+CqJfaAN7AzlmwEx7DtGmx0snFVBST8FVb1Ev1vXosxEnq6/fe7ZDRobFVewvEPQ==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.16.tgz", + "integrity": "sha512-uKnPfsDqDs8bbN54TviAuoGWOmFiQLwNZ3Wvj+OBkJCzwA6QnLb/sSeCB7Pk3ilH4h4jQ0BHtbR+Xpjy9wlOuA==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@mui/private-theming": "^5.6.2", - "@mui/styled-engine": "^5.6.1", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "clsx": "^1.1.1", - "csstype": "^3.0.11", - "prop-types": "^15.7.2" + "@babel/runtime": "^7.23.2", + "@mui/private-theming": "^5.14.16", + "@mui/styled-engine": "^5.14.16", + "@mui/types": "^7.2.8", + "@mui/utils": "^5.14.16", + "clsx": "^2.0.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1" }, "engines": { "node": ">=12.0.0" @@ -3209,11 +2654,11 @@ } }, "node_modules/@mui/types": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.3.tgz", - "integrity": "sha512-DDF0UhMBo4Uezlk+6QxrlDbchF79XG6Zs0zIewlR4c0Dt6GKVFfUtzPtHCH1tTbcSlq/L2bGEdiaoHBJ9Y1gSA==", + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.8.tgz", + "integrity": "sha512-9u0ji+xspl96WPqvrYJF/iO+1tQ1L5GTaDOeG3vCR893yy7VcWwRNiVMmPdPNpMDqx0WV1wtEW9OMwK9acWJzQ==", "peerDependencies": { - "@types/react": "*" + "@types/react": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3222,15 +2667,14 @@ } }, "node_modules/@mui/utils": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.6.1.tgz", - "integrity": "sha512-CPrzrkiBusCZBLWu0Sg5MJvR3fKJyK3gKecLVX012LULyqg2U64Oz04BKhfkbtBrPBbSQxM+DWW9B1c9hmV9nQ==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.16.tgz", + "integrity": "sha512-3xV31GposHkwRbQzwJJuooWpK2ybWdEdeUPtRjv/6vjomyi97F3+68l+QVj9tPTvmfSbr2sx5c/NuvDulrdRmA==", "dependencies": { - "@babel/runtime": "^7.17.2", - "@types/prop-types": "^15.7.4", - "@types/react-is": "^16.7.1 || ^17.0.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" + "@babel/runtime": "^7.23.2", + "@types/prop-types": "^15.7.9", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" }, "engines": { "node": ">=12.0.0" @@ -3240,13 +2684,20 @@ "url": "https://opencollective.com/mui" }, "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "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" @@ -3259,6 +2710,7 @@ "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" } @@ -3267,6 +2719,7 @@ "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" @@ -3275,201 +2728,28 @@ "node": ">= 8" } }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz", - "integrity": "sha512-RbG7h6TuP6nFFYKJwbcToA1rjC1FyPg25NR2noAZ0vKI+la01KTSRPkuVPE+U88jXv7javx2JHglUcL1MHcshQ==", - "dependencies": { - "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.8.1", - "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "@types/webpack": "4.x || 5.x", - "react-refresh": ">=0.10.0 <1.0.0", - "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <3.0.0", - "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x", - "webpack-hot-middleware": "2.x", - "webpack-plugin-serve": "0.x || 1.x" - }, - "peerDependenciesMeta": { - "@types/webpack": { - "optional": true - }, - "sockjs-client": { - "optional": true - }, - "type-fest": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - }, - "webpack-hot-middleware": { - "optional": true - }, - "webpack-plugin-serve": { - "optional": true - } - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, "node_modules/@popperjs/core": { - "version": "2.11.5", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", - "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, - "node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, + "node_modules/@remix-run/router": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.11.0.tgz", + "integrity": "sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ==", "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", - "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/pluginutils/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz", - "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==" - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", - "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==", - "dev": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, - "node_modules/@sindresorhus/is": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@sinonjs/commons": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", - "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", - "dependencies": { - "@sinonjs/commons": "^1.7.0" + "node": ">=14.0.0" } }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", @@ -3477,553 +2757,100 @@ "string.prototype.matchall": "^4.0.6" } }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", - "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", - "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", - "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", - "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", - "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", - "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", - "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", - "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", - "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", - "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", - "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", - "@svgr/babel-plugin-transform-svg-component": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", - "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", - "dependencies": { - "@svgr/plugin-jsx": "^5.5.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^7.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/core/node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", - "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", - "dependencies": { - "@babel/types": "^7.12.6" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", - "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", - "dependencies": { - "@babel/core": "^7.12.3", - "@svgr/babel-preset": "^5.5.0", - "@svgr/hast-util-to-babel-ast": "^5.5.0", - "svg-parser": "^2.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-svgo": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", - "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", - "dependencies": { - "cosmiconfig": "^7.0.0", - "deepmerge": "^4.2.2", - "svgo": "^1.2.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-svgo/node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@svgr/webpack": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", - "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/plugin-transform-react-constant-elements": "^7.12.1", - "@babel/preset-env": "^7.12.1", - "@babel/preset-react": "^7.12.5", - "@svgr/core": "^5.5.0", - "@svgr/plugin-jsx": "^5.5.0", - "@svgr/plugin-svgo": "^5.5.0", - "loader-utils": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", - "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "node_modules/@types/babel__core": { + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", + "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", "dev": true, "dependencies": { - "defer-to-connect": "^1.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.1.19", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", - "integrity": "sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "node_modules/@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", + "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", + "dev": true, "dependencies": { "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", + "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", + "dev": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.17.0.tgz", - "integrity": "sha512-r8aveDbd+rzGP+ykSdF3oPuTVRWRfbBiHl0rVDM2yNEmSMXfkObQLV46b4RnCv3Lra51OlfnZhkkFaDl2MIRaA==", - "dependencies": { - "@babel/types": "^7.3.0" - } - }, - "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==", - "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==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", - "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/debug": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", - "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", + "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", "dev": true, "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/eslint": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", - "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", - "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==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" + "@babel/types": "^7.20.7" } }, "node_modules/@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" - }, - "node_modules/@types/express": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", - "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.28", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", - "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "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, - "optional": true, - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.8", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.8.tgz", - "integrity": "sha512-5kPLG5BKpWYkw/LVOGWpiq3nEVqxiN32rTgI53Sk12/xHFQ2rG3ehI9IO+O3W2QoKeyB92dJkoka8SUm6BX1pA==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" - }, - "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==" - }, - "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, - "optional": true - }, - "node_modules/@types/ms": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", - "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/node": { - "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.26.tgz", - "integrity": "sha512-z/FG/6DUO7pnze3AE3TBGIjGGKkvCcGcWINe1C7cADY8hKLJPDYpzsNE37uExQ4md5RFtTCvg+M8Mu1Enyeg2A==" - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "node_modules/@types/plist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz", - "integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==", - "dev": true, - "optional": true, + "node_modules/@types/mdast": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.14.tgz", + "integrity": "sha512-gVZ04PGgw1qLZKsnWnyFv4ORnaJ+DXLdHTVSFbU8yX6xZ34Bjg4Q32yPkmveUP1yItXReKfB0Aknlh/3zxTKAw==", "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "@types/unist": "^2" } }, - "node_modules/@types/prettier": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.0.tgz", - "integrity": "sha512-G/AdOadiZhnJp0jXCaBQU449W2h716OW/EoXeYkCytxKL06X1WCXB4DZpp8TpZ8eyIJVS1cw4lrlkkSYU21cDw==" + "node_modules/@types/node": { + "version": "20.8.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", + "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.1.tgz", + "integrity": "sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==" }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" - }, - "node_modules/@types/q": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", - "integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==" - }, - "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==" - }, - "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==" + "version": "15.7.9", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", + "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==" }, "node_modules/@types/react": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.6.tgz", - "integrity": "sha512-bPqwzJRzKtfI0mVYr5R+1o9BOE8UEXefwc1LwcBtfnaAn6OoqMhLa/91VA8aeWfDPJt1kHvYKI8RHcQybZLHHA==", + "version": "18.2.35", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.35.tgz", + "integrity": "sha512-LG3xpFZ++rTndV+/XFyX5vUP7NI9yxyk+MQvBDq+CVs8I9DLSc3Ymwb1Vmw5YDoeNeHN4PDZa3HylMKJYT9PNQ==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, - "node_modules/@types/react-is": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz", - "integrity": "sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.8.tgz", + "integrity": "sha512-QmQ22q+Pb+HQSn04NL3HtrqHwYMf4h3QKArOy5F8U5nEVMaihBs3SR10WiOM1iwPz5jIo8x/u11al+iEGZZrvg==", "dependencies": { "@types/react": "*" } @@ -4032,493 +2859,57 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, "dependencies": { "@types/node": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz", - "integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==" - }, "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, - "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==", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "dependencies": { - "@types/mime": "^1", - "@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==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", + "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==" }, "node_modules/@types/trusted-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", - "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" - }, - "node_modules/@types/verror": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.5.tgz", - "integrity": "sha512-9UjMCHK5GPgQRoNbqdLIAvAy0EInuiqbW0PBMtVP6B5B2HQJlvoJHM+KodPZMEjOa5VkSc+5LH7xy+cUzQdmHw==", - "dev": true, - "optional": true - }, - "node_modules/@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", - "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.20.0.tgz", - "integrity": "sha512-fapGzoxilCn3sBtC6NtXZX6+P/Hef7VDbyfGqTTpzYydwhlkevB+0vE0EnmHPVTVSy68GUncyJ/2PcrFBeCo5Q==", - "dependencies": { - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/type-utils": "5.20.0", - "@typescript-eslint/utils": "5.20.0", - "debug": "^4.3.2", - "functional-red-black-tree": "^1.0.1", - "ignore": "^5.1.8", - "regexpp": "^3.2.0", - "semver": "^7.3.5", - "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/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.20.0.tgz", - "integrity": "sha512-w5qtx2Wr9x13Dp/3ic9iGOGmVXK5gMwyc8rwVgZU46K9WTjPZSyPvdER9Ycy+B5lNHvoz+z2muWhUvlTpQeu+g==", - "dependencies": { - "@typescript-eslint/utils": "5.20.0" - }, - "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/parser": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.20.0.tgz", - "integrity": "sha512-UWKibrCZQCYvobmu3/N8TWbEeo/EPQbS41Ux1F9XqPzGuV7pfg6n50ZrFo6hryynD8qOTTfLHtHjjdQtxJ0h/w==", - "dependencies": { - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/typescript-estree": "5.20.0", - "debug": "^4.3.2" - }, - "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.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.20.0.tgz", - "integrity": "sha512-h9KtuPZ4D/JuX7rpp1iKg3zOH0WNEa+ZIXwpW/KWmEFDxlA/HSfCMhiyF1HS/drTICjIbpA6OqkAhrP/zkCStg==", - "dependencies": { - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/visitor-keys": "5.20.0" - }, - "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.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.20.0.tgz", - "integrity": "sha512-WxNrCwYB3N/m8ceyoGCgbLmuZwupvzN0rE8NBuwnl7APgjv24ZJIjkNzoFBXPRCGzLNkoU/WfanW0exvp/+3Iw==", - "dependencies": { - "@typescript-eslint/utils": "5.20.0", - "debug": "^4.3.2", - "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/types": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.20.0.tgz", - "integrity": "sha512-+d8wprF9GyvPwtoB4CxBAR/s0rpP25XKgnOvMf/gMXYDvlUC3rPFHupdTQ/ow9vn7UDe5rX02ovGYQbv/IUCbg==", - "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.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.20.0.tgz", - "integrity": "sha512-36xLjP/+bXusLMrT9fMMYy1KJAGgHhlER2TqpUVDYUQg4w0q/NW/sg4UGAgVwAqb8V4zYg43KMUpM8vV2lve6w==", - "dependencies": { - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/visitor-keys": "5.20.0", - "debug": "^4.3.2", - "globby": "^11.0.4", - "is-glob": "^4.0.3", - "semver": "^7.3.5", - "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/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.20.0.tgz", - "integrity": "sha512-lHONGJL1LIO12Ujyx8L8xKbwWSkoUKFSO+0wDAqGXiudWB2EO7WEUT+YZLtVbmOmSllAjLb9tpoIPwpRe5Tn6w==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/typescript-estree": "5.20.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" - }, - "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/utils/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==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.20.0.tgz", - "integrity": "sha512-1flRpNF+0CAQkMNlTJ6L/Z5jiODG/e5+7mk6XwtPOUS3UrTz3UOiAg9jG2VtKsWI6rZQfy4C6a232QNRZTRGlg==", - "dependencies": { - "@typescript-eslint/types": "5.20.0", - "eslint-visitor-keys": "^3.0.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@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==" - }, - "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==" - }, - "node_modules/7zip-bin": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.1.1.tgz", - "integrity": "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.5.tgz", + "integrity": "sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==", "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==" + "node_modules/@types/unist": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.9.tgz", + "integrity": "sha512-zC0iXxAv1C1ERURduJueYzkzZ2zaGyc+P2c95hgkikHPr3z8EdUZOlgEQ5X0DRmwDZn+hekycQnoeiiRVrmilQ==" }, - "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==", + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.1.1.tgz", + "integrity": "sha512-Jie2HERK+uh27e+ORXXwEP5h0Y2lS9T2PRGbfebiHGlwzDO0dEnd2aNtOR/qjBlPb1YgxwAONeblL1xqLikLag==", + "dev": true, "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "@babel/core": "^7.23.2", + "@babel/plugin-transform-react-jsx-self": "^7.22.5", + "@babel/plugin-transform-react-jsx-source": "^7.22.5", + "@types/babel__core": "^7.20.3", + "react-refresh": "^0.14.0" }, "engines": { - "node": ">= 0.6" + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0" } }, "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==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -4526,106 +2917,20 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "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==", - "peerDependencies": { - "acorn": "^8" - } - }, "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/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-node/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/address": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.1.2.tgz", - "integrity": "sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==", - "engines": { - "node": ">= 0.12.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==", - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "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==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "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", @@ -4637,88 +2942,11 @@ "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==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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/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==" - }, - "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==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "dev": true, - "dependencies": { - "string-width": "^4.1.0" - } - }, - "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==", - "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==", - "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==", + "dev": true, "engines": { "node": ">=8" } @@ -4734,146 +2962,44 @@ "node": ">=4" } }, - "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/app-builder-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", - "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", - "dev": true - }, - "node_modules/app-builder-lib": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-23.0.3.tgz", - "integrity": "sha512-1qrtXYHXJfXhzJnMtVGjIva3067F1qYQubl2oBjI61gCBoCHvhghdYJ57XxXTQQ0VxnUhg1/Iaez87uXp8mD8w==", - "dev": true, - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/universal": "1.2.0", - "@malept/flatpak-bundler": "^0.4.0", - "7zip-bin": "~5.1.1", - "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.9", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chromium-pickle-js": "^0.2.0", - "debug": "^4.3.2", - "ejs": "^3.1.6", - "electron-osx-sign": "^0.6.0", - "electron-publish": "23.0.2", - "form-data": "^4.0.0", - "fs-extra": "^10.0.0", - "hosted-git-info": "^4.0.2", - "is-ci": "^3.0.0", - "isbinaryfile": "^4.0.8", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "minimatch": "^3.0.4", - "read-config-file": "6.2.0", - "sanitize-filename": "^1.6.3", - "semver": "^7.3.5", - "temp-file": "^3.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/argparse": { + "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/app-builder-lib/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/app-builder-lib/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/app-builder-lib/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - }, - "engines": { - "node": ">=6.0" + "dequal": "^2.0.3" } }, - "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==" - }, - "node_modules/array-includes": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", - "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1", - "get-intrinsic": "^1.1.1", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -4883,22 +3009,34 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array.prototype.flat": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz", - "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -4909,13 +3047,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz", - "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -4925,427 +3064,101 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" - }, - "node_modules/asar": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/asar/-/asar-3.1.0.tgz", - "integrity": "sha512-vyxPxP5arcAqN4F/ebHd/HhwnAiZtwhglvdmc7BR2f0ywbVNTOpSeyhLDbGXtE/y58hv1oC75TaNIXutnsOZsQ==", + "node_modules/array.prototype.tosorted": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", + "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", "dev": true, "dependencies": { - "chromium-pickle-js": "^0.2.0", - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - }, - "optionalDependencies": { - "@types/glob": "^7.1.1" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" } }, - "node_modules/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, "engines": { - "node": ">= 6" - } - }, - "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, - "optional": true, - "engines": { - "node": ">=0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=" - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - } + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true }, "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dependencies": { - "lodash": "^4.17.14" - } + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "dev": true }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", "dev": true, - "engines": { - "node": ">=0.12.0" + "dependencies": { + "has-symbols": "^1.0.3" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "engines": { "node": ">= 4.0.0" } }, - "node_modules/autoprefixer": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.5.tgz", - "integrity": "sha512-Fvd8yCoA7lNX/OUllvS+aS1I7WRBclGXsepbvT8ZaPgrH24rgXpZzF0/6Hh3ZEkwg+0AES/Osd196VZmYoEFtw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001332", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.1.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/axe-core": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz", - "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", + "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "dev": true, "engines": { "node": ">=4" } }, - "node_modules/axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "node_modules/axobject-query": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", "dev": true, "dependencies": { - "follow-redirects": "^1.14.7" - } - }, - "node_modules/axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==" - }, - "node_modules/babel-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", - "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", - "dependencies": { - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-jest/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/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/babel-jest/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/babel-jest/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/babel-jest/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-loader": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", - "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", - "dependencies": { - "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", - "make-dir": "^3.1.0", - "schema-utils": "^2.6.5" - }, - "engines": { - "node": ">= 8.9" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "webpack": ">=2" - } - }, - "node_modules/babel-loader/node_modules/schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", - "dependencies": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/babel-plugin-dynamic-import-node": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", - "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", - "dependencies": { - "object.assign": "^4.1.0" - } - }, - "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==", - "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-jest-hoist": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", - "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.0.0", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "dequal": "^2.0.3" } }, "node_modules/babel-plugin-macros": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", - "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", - "dependencies": { - "@babel/runtime": "^7.7.2", - "cosmiconfig": "^6.0.0", - "resolve": "^1.12.0" - } - }, - "node_modules/babel-plugin-named-asset-import": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", - "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", - "peerDependencies": { - "@babel/core": "^7.1.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz", - "integrity": "sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==", - "dependencies": { - "@babel/compat-data": "^7.13.11", - "@babel/helper-define-polyfill-provider": "^0.3.1", - "semver": "^6.1.1" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz", - "integrity": "sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.3.1", - "core-js-compat": "^3.21.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz", - "integrity": "sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-plugin-transform-react-remove-prop-types": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", - "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==" - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@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-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", - "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", - "dependencies": { - "babel-plugin-jest-hoist": "^27.5.1", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-react-app": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", - "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", - "dependencies": { - "@babel/core": "^7.16.0", - "@babel/plugin-proposal-class-properties": "^7.16.0", - "@babel/plugin-proposal-decorators": "^7.16.4", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", - "@babel/plugin-proposal-numeric-separator": "^7.16.0", - "@babel/plugin-proposal-optional-chaining": "^7.16.0", - "@babel/plugin-proposal-private-methods": "^7.16.0", - "@babel/plugin-transform-flow-strip-types": "^7.16.0", - "@babel/plugin-transform-react-display-name": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.4", - "@babel/preset-env": "^7.16.4", - "@babel/preset-react": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", - "@babel/runtime": "^7.16.3", - "babel-plugin-macros": "^3.1.0", - "babel-plugin-transform-react-remove-prop-types": "^0.4.24" - } - }, - "node_modules/babel-preset-react-app/node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", @@ -5359,278 +3172,65 @@ "npm": ">=6" } }, - "node_modules/babel-preset-react-app/node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", + "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", + "dev": true, "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.4.3", + "semver": "^6.3.1" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", + "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.3", + "core-js-compat": "^3.33.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", + "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "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/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=" - }, - "node_modules/bfj": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", - "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", - "dependencies": { - "bluebird": "^3.5.5", - "check-types": "^11.1.1", - "hoopy": "^0.1.4", - "tryer": "^1.0.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "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==", - "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/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "node_modules/bluebird-lst": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", - "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", - "dev": true, - "dependencies": { - "bluebird": "^3.5.5" - } - }, - "node_modules/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.8.1", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.9.7", - "raw-body": "2.4.3", - "type-is": "~1.6.18" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "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==", - "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==", - "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=" - }, - "node_modules/bonjour-service": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", - "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", - "dependencies": { - "array-flatten": "^2.1.2", - "dns-equal": "^1.0.0", - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.4" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "dev": true, - "optional": true - }, - "node_modules/boxen": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", - "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", - "dev": true, - "dependencies": { - "ansi-align": "^3.0.0", - "camelcase": "^6.2.0", - "chalk": "^4.1.0", - "cli-boxes": "^2.2.1", - "string-width": "^4.2.2", - "type-fest": "^0.20.2", - "widest-line": "^3.1.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/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/boxen/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/boxen/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/boxen/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==", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/boxen/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/boxen/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/boxen/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/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" @@ -5640,6 +3240,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -5647,15 +3248,11 @@ "node": ">=8" } }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" - }, "node_modules/browserslist": { - "version": "4.20.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", - "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5664,14 +3261,17 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "caniuse-lite": "^1.0.30001332", - "electron-to-chromium": "^1.4.118", - "escalade": "^3.1.1", - "node-releases": "^2.0.3", - "picocolors": "^1.0.0" + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" }, "bin": { "browserslist": "cli.js" @@ -5680,237 +3280,17 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dependencies": { - "node-int64": "^0.4.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" - } - ], - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", - "integrity": "sha1-WWFrSYME1Var1GaWayLu2j7KX74=", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "dev": true - }, "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==" - }, - "node_modules/builder-util": { - "version": "23.0.2", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-23.0.2.tgz", - "integrity": "sha512-HaNHL3axNW/Ms8O1mDx3I07G+ZnZ/TKSWWvorOAPau128cdt9S+lNx5ocbx8deSaHHX4WFXSZVHh3mxlaKJNgg==", - "dev": true, - "dependencies": { - "@types/debug": "^4.1.6", - "@types/fs-extra": "^9.0.11", - "7zip-bin": "~5.1.1", - "app-builder-bin": "4.0.0", - "bluebird-lst": "^1.0.9", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "cross-spawn": "^7.0.3", - "debug": "^4.3.2", - "fs-extra": "^10.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-ci": "^3.0.0", - "js-yaml": "^4.1.0", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.0.0.tgz", - "integrity": "sha512-SkpEtSmTkREDHRJnxKEv43aAYp8sYWY8fxYBhGLBLOBIRXeaIp6Kv3lBgSD7uR8jQtC7CA659sqJrpSV6zNvSA==", - "dev": true, - "dependencies": { - "debug": "^4.3.2", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/builder-util/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/builder-util/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/builder-util/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==", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/builder-util/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/builder-util/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/builder-util/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/builder-util/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/builder-util/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/builder-util/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/builder-util/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/builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "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" }, @@ -5918,72 +3298,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacheable-request": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", - "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", - "dev": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^3.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^4.1.0", - "responselike": "^1.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cacheable-request/node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/normalize-url": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", - "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5997,49 +3320,11 @@ "node": ">=6" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "dependencies": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001332", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz", - "integrity": "sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw==", + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6048,17 +3333,13 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, - "node_modules/case-sensitive-paths-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", - "engines": { - "node": ">=4" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -6075,195 +3356,46 @@ "node_modules/chalk/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=", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "engines": { "node": ">=0.8.0" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "engines": { - "node": ">=10" - } - }, - "node_modules/charcodes": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/charcodes/-/charcodes-0.2.0.tgz", - "integrity": "sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/check-types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz", - "integrity": "sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==" - }, - "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/chokidar/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/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==", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha1-BKEGZywYsIWrd02YPfo+oTjyIgU=", - "dev": true - }, - "node_modules/ci-info": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", - "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" - }, - "node_modules/cjs-module-lexer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" - }, - "node_modules/clean-css": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", - "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clean-css/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cli-boxes": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", - "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", - "dev": true, - "engines": { - "node": ">=6" - }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "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==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/clone-response": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", - "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", - "dev": true, - "dependencies": { - "mimic-response": "^1.0.0" + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", "engines": { "node": ">=6" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/coa": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", - "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", - "dependencies": { - "@types/q": "^1.5.1", - "chalk": "^2.4.1", - "q": "^1.1.2" - }, - "engines": { - "node": ">= 4.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" - }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -6275,434 +3407,75 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, - "node_modules/colord": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz", - "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==" - }, - "node_modules/colorette": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", - "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==" - }, - "node_modules/colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", - "dev": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/combined-stream": { + "node_modules/comma-separated-tokens": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "engines": { - "node": ">= 12" - } - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" + "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==", + "dev": true }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, "engines": { "node": ">=4.0.0" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" - }, - "node_modules/compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha1-AWLsLZNR9d3VmpICy6k1NmpyUIA=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "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==", - "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/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "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": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, "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=" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concat-stream/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/concat-stream/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/concurrently": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.1.0.tgz", - "integrity": "sha512-Bz0tMlYKZRUDqJlNiF/OImojMB9ruKUz6GCfmhFnSapXgPe+3xzY4byqoKG9tUZ7L2PGEUjfLPOLfIX3labnmw==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "date-fns": "^2.16.1", - "lodash": "^4.17.21", - "rxjs": "^6.6.3", - "spawn-command": "^0.0.2-1", - "supports-color": "^8.1.0", - "tree-kill": "^1.2.2", - "yargs": "^16.2.0" - }, - "bin": { - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.0 || >=16.0.0" - } - }, - "node_modules/concurrently/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/concurrently/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/concurrently/node_modules/chalk/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/concurrently/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/concurrently/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==", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/concurrently/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/concurrently/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/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "optional": true, - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/configstore": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", - "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", - "dev": true, - "dependencies": { - "dot-prop": "^5.2.0", - "graceful-fs": "^4.1.2", - "make-dir": "^3.0.0", - "unique-string": "^2.0.0", - "write-file-atomic": "^3.0.0", - "xdg-basedir": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==" - }, - "node_modules/connect-history-api-fallback": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", - "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==", - "engines": { - "node": ">=0.8" - } - }, - "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==", - "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==", - "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==", - "engines": { - "node": ">= 0.6" - } + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true }, "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==", - "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": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" - }, - "node_modules/core-js": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.2.tgz", - "integrity": "sha512-Z5I2vzDnEIqO2YhELVMFcL1An2CIsFe9Q7byZhs8c/QxummxZlAHw33TUHbIte987LkisOgL0LwQ1P9D6VISnA==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/core-js-compat": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.2.tgz", - "integrity": "sha512-Fns9lU06ZJ07pdfmPMu7OnkIKGPKDzXKIiuGlSvHHapwqMUF2QnnsWwtueFZtSyZEilP0o6iUeHQwpn7LxtLUw==", + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.2.tgz", + "integrity": "sha512-axfo+wxFVxnqf8RvxTzoAlzW4gRoacrHeoFlc9n0x50+7BEyZL/Rt3hicaED1/CEd7I6tPCPVUYcJwCMO5XUYw==", + "dev": true, "dependencies": { - "browserslist": "^4.20.2", - "semver": "7.0.0" + "browserslist": "^4.22.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, - "node_modules/core-js-compat/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/core-js-pure": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.2.tgz", - "integrity": "sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, "node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dependencies": { "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", + "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", - "yaml": "^1.7.2" + "yaml": "^1.10.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "optional": true, - "dependencies": { - "buffer": "^5.1.0" + "node": ">=10" } }, "node_modules/cross-fetch": { @@ -6717,6 +3490,7 @@ "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", @@ -6730,458 +3504,29 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/css-blank-pseudo": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", - "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "bin": { - "css-blank-pseudo": "dist/cli.cjs" - }, + "node_modules/cssjanus": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.1.0.tgz", + "integrity": "sha512-kAijbny3GmdOi9k+QT6DGIXqFvL96aksNlGr4Rhk9qXDZYWUojU4bRc3IHWxdaLNOqgEZHuXoe5Wl2l7dxLW5g==", "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" + "node": ">=10.0.0" } }, - "node_modules/css-declaration-sorter": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz", - "integrity": "sha512-Ufadglr88ZLsrvS11gjeu/40Lw74D9Am/Jpr3LlYm5Q4ZP5KdlUhG+6u2EjyXeZcxmZ2h1ebCKngDjolpeLHpg==", - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/css-has-pseudo": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", - "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "bin": { - "css-has-pseudo": "dist/cli.cjs" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-loader": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", - "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.7", - "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.5" - }, - "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.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/css-minimizer-webpack-plugin": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", - "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", - "dependencies": { - "cssnano": "^5.0.6", - "jest-worker": "^27.0.2", - "postcss": "^8.3.5", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@parcel/css": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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/css-minimizer-webpack-plugin/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==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/css-minimizer-webpack-plugin/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==" - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/css-minimizer-webpack-plugin/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/css-prefers-color-scheme": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", - "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", - "bin": { - "css-prefers-color-scheme": "dist/cli.cjs" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "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==", - "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-select-base-adapter": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", - "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" - }, - "node_modules/css-tree": { - "version": "1.0.0-alpha.37", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", - "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", - "dependencies": { - "mdn-data": "2.0.4", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/css-tree/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "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==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssdb": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-6.5.0.tgz", - "integrity": "sha512-Rh7AAopF2ckPXe/VBcoUS9JrCZNSyc60+KpgE6X25vpVxA32TmiqvExjkfhwP4wGSb6Xe8Z/JIyGqwgx/zZYFA==" - }, - "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==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssnano": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.7.tgz", - "integrity": "sha512-pVsUV6LcTXif7lvKKW9ZrmX+rGRzxkEdJuVJcp5ftUjWITgwam5LMZOgaTvUrWPkcORBey6he7JKb4XAJvrpKg==", - "dependencies": { - "cssnano-preset-default": "^5.2.7", - "lilconfig": "^2.0.3", - "yaml": "^1.10.2" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/cssnano-preset-default": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.7.tgz", - "integrity": "sha512-JiKP38ymZQK+zVKevphPzNSGHSlTI+AOwlasoSRtSVMUU285O7/6uZyd5NbW92ZHp41m0sSHe6JoZosakj63uA==", - "dependencies": { - "css-declaration-sorter": "^6.2.2", - "cssnano-utils": "^3.1.0", - "postcss-calc": "^8.2.3", - "postcss-colormin": "^5.3.0", - "postcss-convert-values": "^5.1.0", - "postcss-discard-comments": "^5.1.1", - "postcss-discard-duplicates": "^5.1.0", - "postcss-discard-empty": "^5.1.1", - "postcss-discard-overridden": "^5.1.0", - "postcss-merge-longhand": "^5.1.4", - "postcss-merge-rules": "^5.1.1", - "postcss-minify-font-values": "^5.1.0", - "postcss-minify-gradients": "^5.1.1", - "postcss-minify-params": "^5.1.2", - "postcss-minify-selectors": "^5.2.0", - "postcss-normalize-charset": "^5.1.0", - "postcss-normalize-display-values": "^5.1.0", - "postcss-normalize-positions": "^5.1.0", - "postcss-normalize-repeat-style": "^5.1.0", - "postcss-normalize-string": "^5.1.0", - "postcss-normalize-timing-functions": "^5.1.0", - "postcss-normalize-unicode": "^5.1.0", - "postcss-normalize-url": "^5.1.0", - "postcss-normalize-whitespace": "^5.1.1", - "postcss-ordered-values": "^5.1.1", - "postcss-reduce-initial": "^5.1.0", - "postcss-reduce-transforms": "^5.1.0", - "postcss-svgo": "^5.1.0", - "postcss-unique-selectors": "^5.1.1" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/cssnano-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", - "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/csso": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", - "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", - "dependencies": { - "css-tree": "^1.1.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", - "dependencies": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "node_modules/csso/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" - }, "node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" - }, - "node_modules/data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", - "dependencies": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/data-urls/node_modules/tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", - "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/date-fns": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", - "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", - "dev": true, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true }, "node_modules/debug": { "version": "4.3.4", @@ -7199,80 +3544,42 @@ } } }, - "node_modules/decimal.js": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", - "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==" - }, - "node_modules/decompress-response": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", - "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", - "dev": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=" - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, "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==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "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==", + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, "dependencies": { - "execa": "^5.0.0" + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" }, "engines": { - "node": ">= 10" - } - }, - "node_modules/defer-to-connect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", - "dev": true - }, - "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==", - "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "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" }, @@ -7283,258 +3590,38 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "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=", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "engines": { - "node": ">=8" - } - }, - "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==" - }, - "node_modules/detect-port-alt": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", - "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", - "dependencies": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "bin": { - "detect": "bin/detect-port", - "detect-port": "bin/detect-port" - }, - "engines": { - "node": ">= 4.2.1" - } - }, - "node_modules/detect-port-alt/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/detect-port-alt/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "node_modules/detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dependencies": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" + "node": ">=6" } }, "node_modules/dexie": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.1.tgz", - "integrity": "sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.4.tgz", + "integrity": "sha512-VKoTQRSv7+RnffpOJ3Dh6ozknBqzWw/F3iqMdsZg958R0AS8AnY9x9d1lbwENr0gzeGJHXKcGhAMRaqys6SxqA==", "engines": { "node": ">=6.0" } }, "node_modules/dexie-react-hooks": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.1.tgz", - "integrity": "sha512-Cam5JP6PxHN564RvWEoe8cqLhosW0O4CAZ9XEVYeGHJBa6KEJlOpd9CUpV3kmU9dm2MrW97/lk7qkf1xpij7gA==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.7.tgz", + "integrity": "sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==", "peerDependencies": { "@types/react": ">=16", - "dexie": ">=3.1.0-alpha.1 <5.0.0", + "dexie": "^3.2 || ^4.0.1-alpha", "react": ">=16" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "node_modules/diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/dir-compare": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-2.4.0.tgz", - "integrity": "sha512-l9hmu8x/rjVC9Z2zmGzkhOEowZvW7pmYws5CWHutg8u1JgvsKWMx7Q/UODeu4djLZ4FgW5besw5yvMQnBHzuCA==", - "dev": true, - "dependencies": { - "buffer-equal": "1.0.0", - "colors": "1.0.3", - "commander": "2.9.0", - "minimatch": "3.0.4" - }, - "bin": { - "dircompare": "src/cli/dircompare.js" - } - }, - "node_modules/dir-compare/node_modules/commander": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", - "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", - "dev": true, - "dependencies": { - "graceful-readlink": ">= 1.0.0" - }, - "engines": { - "node": ">= 0.6.x" - } - }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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==", - "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==" - }, - "node_modules/dmg-builder": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-23.0.3.tgz", - "integrity": "sha512-mBYrHHnSM5PC656TDE+xTGmXIuWHAGmmRfyM+dV0kP+AxtwPof4pAXNQ8COd0/exZQ4dqf72FiPS3B9G9aB5IA==", - "dev": true, - "dependencies": { - "app-builder-lib": "23.0.3", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "fs-extra": "^10.0.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.9" - } - }, - "node_modules/dmg-builder/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/dmg-builder/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/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "deprecated": "Disk image license agreements are deprecated by Apple and will probably be removed in a future macOS release. Discussion at: https://github.com/argv-minus-one/dmg-license/issues/11", - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dns-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=" - }, - "node_modules/dns-packet": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz", - "integrity": "sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==", - "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" }, @@ -7542,14 +3629,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "dependencies": { - "utila": "~0.4" - } - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -7559,139 +3638,11 @@ "csstype": "^3.0.2" } }, - "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==", - "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==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", - "dependencies": { - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "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/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dot-prop/node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", - "engines": { - "node": ">=10" - } - }, - "node_modules/dotenv-expand": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", - "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" - }, - "node_modules/duplexer3": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", - "dev": true - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" - }, "node_modules/ejs": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz", - "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==", + "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" }, @@ -7702,390 +3653,17 @@ "node": ">=0.10.0" } }, - "node_modules/electron": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-18.2.0.tgz", - "integrity": "sha512-AN+CKalzA57beuvuI90PVgW/yj6zjw7rpb1h8FvIwBJ3toDC3x0Plfzbzh4Ondecbjci7pSg/NA5ngOk804WIQ==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@electron/get": "^1.13.0", - "@types/node": "^16.11.26", - "extract-zip": "^1.0.3" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 8.6" - } - }, - "node_modules/electron-builder": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-23.0.3.tgz", - "integrity": "sha512-0lnTsljAgcOMuIiOjPcoFf+WxOOe/O04hZPgIvvUBXIbz3kolbNu0Xdch1f5WuQ40NdeZI7oqs8Eo395PcuGHQ==", - "dev": true, - "dependencies": { - "@types/yargs": "^17.0.1", - "app-builder-lib": "23.0.3", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "dmg-builder": "23.0.3", - "fs-extra": "^10.0.0", - "is-ci": "^3.0.0", - "lazy-val": "^1.0.5", - "read-config-file": "6.2.0", - "update-notifier": "^5.1.0", - "yargs": "^17.0.1" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/electron-builder/node_modules/@types/yargs": { - "version": "17.0.10", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", - "integrity": "sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/electron-builder/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/electron-builder/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/electron-builder/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/electron-builder/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/electron-builder/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/electron-builder/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/electron-builder/node_modules/yargs": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.4.1.tgz", - "integrity": "sha512-WSZD9jgobAg3ZKuCQZSa3g9QOJeCCqLoLAykiWgmXnDo9EPnn4RPf5qVTtzgOx66o6/oqhcA5tHtJXpG8pMt3g==", - "dev": 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.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder/node_modules/yargs-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", - "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-is-dev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-2.0.0.tgz", - "integrity": "sha512-3X99K852Yoqu9AcW50qz3ibYBWY79/pBhlMCab8ToEWS48R0T9tyxRiQhwylE7zQdXrMnx2JKqUJyMPmt5FBqA==", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/electron-osx-sign": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.6.0.tgz", - "integrity": "sha512-+hiIEb2Xxk6eDKJ2FFlpofCnemCbjbT5jz+BKGpVBrRNT3kWTGs4DfNX6IzGwgi33hUcXF+kFs9JW+r6Wc1LRg==", - "dev": true, - "dependencies": { - "bluebird": "^3.5.0", - "compare-version": "^0.1.2", - "debug": "^2.6.8", - "isbinaryfile": "^3.0.2", - "minimist": "^1.2.0", - "plist": "^3.0.1" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/electron-osx-sign/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/electron-osx-sign/node_modules/isbinaryfile": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.3.tgz", - "integrity": "sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==", - "dev": true, - "dependencies": { - "buffer-alloc": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/electron-osx-sign/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/electron-publish": { - "version": "23.0.2", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-23.0.2.tgz", - "integrity": "sha512-8gMYgWqv96lc83FCm85wd+tEyxNTJQK7WKyPkNkO8GxModZqt1GO8S+/vAnFGxilS/7vsrVRXFfqiCDUCSuxEg==", - "dev": true, - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "fs-extra": "^10.0.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" - } - }, - "node_modules/electron-publish/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/electron-publish/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/electron-publish/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/electron-publish/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/electron-publish/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/electron-publish/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, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/electron-publish/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/electron-to-chromium": { - "version": "1.4.118", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.118.tgz", - "integrity": "sha512-maZIKjnYDvF7Fs35nvVcyr44UcKNwybr93Oba2n3HkKDFAtk0svERkLN/HyczJDS3Fo4wU9th9fUQd09ZLtj1w==" - }, - "node_modules/electron/node_modules/@types/node": { - "version": "16.11.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.33.tgz", - "integrity": "sha512-0PJ0vg+JyU0MIan58IOIFRtSvsb7Ri+7Wltx2qAg94eMOrpg4+uuP3aUHCpxXc1i0jCXiC+zIamSZh3l9AbcQA==", + "version": "1.4.576", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", + "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==", "dev": true }, - "node_modules/emittery": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", - "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "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==" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "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=", - "engines": { - "node": ">= 0.8" - } - }, - "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/enhanced-resolve": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz", - "integrity": "sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "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" - } + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true }, "node_modules/error-ex": { "version": "1.3.2", @@ -8096,38 +3674,58 @@ } }, "node_modules/error-stack-parser": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.7.tgz", - "integrity": "sha512-chLOW0ZGRf4s8raLrDxa5sdkvPec5YdvwbFnqJme4rk0rFajP8mPtrDL1+I+CwrQDCjswDA5sREX7jYQDQs9vA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "dependencies": { - "stackframe": "^1.1.1" + "stackframe": "^1.3.4" } }, "node_modules/es-abstract": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz", - "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", - "has": "^1.0.3", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.0", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -8136,23 +3734,56 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" } }, "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", @@ -8165,35 +3796,52 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, - "optional": true + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/escape-goat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", - "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8205,123 +3853,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/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==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.14.0.tgz", - "integrity": "sha512-3/CE4aJX7LNEiE3i6FeodHmI/38GZtWCsAtsymScmzYapx8q1nVVb+eLcLSzATmCPXw5pT4TqVs1E0OmxAd9tw==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", + "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "dev": true, "dependencies": { - "@eslint/eslintrc": "^1.2.2", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.3", + "@eslint/js": "8.53.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "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.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", "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-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" @@ -8333,166 +3908,127 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-config-react-app": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", - "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", + "node_modules/eslint-config-airbnb": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", + "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", + "dev": true, "dependencies": { - "@babel/core": "^7.16.0", - "@babel/eslint-parser": "^7.16.3", - "@rushstack/eslint-patch": "^1.1.0", - "@typescript-eslint/eslint-plugin": "^5.5.0", - "@typescript-eslint/parser": "^5.5.0", - "babel-preset-react-app": "^10.0.1", - "confusing-browser-globals": "^1.0.11", - "eslint-plugin-flowtype": "^8.0.3", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jest": "^25.3.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.27.1", - "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-testing-library": "^5.0.1" + "eslint-config-airbnb-base": "^15.0.0", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5" }, "engines": { - "node": ">=14.0.0" + "node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0" }, "peerDependencies": { - "eslint": "^8.0.0" + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-hooks": "^4.3.0" + } + }, + "node_modules/eslint-config-airbnb-base": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", + "dev": true, + "dependencies": { + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.2" + } + }, + "node_modules/eslint-config-prettier": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, "dependencies": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/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/eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, "dependencies": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "engines": { "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/eslint-module-utils/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/eslint-module-utils/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-plugin-flowtype": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", - "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", - "dependencies": { - "lodash": "^4.17.21", - "string-natural-compare": "^3.0.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@babel/plugin-syntax-flow": "^7.14.5", - "@babel/plugin-transform-react-jsx": "^7.14.9", - "eslint": "^8.1.0" - } - }, "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "dev": true, "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "engines": { "node": ">=4" @@ -8502,17 +4038,19 @@ } }, "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -8520,51 +4058,28 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "node_modules/eslint-plugin-jest": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", - "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", - "dependencies": { - "@typescript-eslint/experimental-utils": "^5.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", - "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", + "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.16.3", - "aria-query": "^4.2.2", - "array-includes": "^3.1.4", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.3.5", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.7", + "@babel/runtime": "^7.23.2", + "aria-query": "^5.3.0", + "array-includes": "^3.1.7", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "=4.7.0", + "axobject-query": "^3.2.1", + "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.2.1", - "language-tags": "^1.0.5", - "minimatch": "^3.0.4" + "es-iterator-helpers": "^1.0.15", + "hasown": "^2.0.0", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.entries": "^1.1.7", + "object.fromentries": "^2.0.7" }, "engines": { "node": ">=4.0" @@ -8574,24 +4089,27 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.29.4.tgz", - "integrity": "sha512-CVCXajliVh509PcZYRFyu/BoUEz452+jtQJq2b3Bae4v3xBUWPLCmtmBM+ZinG4MzwmxJgJ2M5rMqhqLVn7MtQ==", + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", + "dev": true, "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flatmap": "^1.2.5", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.5", - "object.fromentries": "^2.0.5", - "object.hasown": "^1.1.0", - "object.values": "^1.1.5", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.6" + "resolve": "^2.0.0-next.4", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.8" }, "engines": { "node": ">=4" @@ -8601,9 +4119,10 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.4.0.tgz", - "integrity": "sha512-U3RVIfdzJaeKDQKEJbz5p3NW8/L80PCATJAfuojwbaEL+gBjfGdhUcGde+WGUW46Q5sr/NgxevsIiDtNXrvZaQ==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, "engines": { "node": ">=10" }, @@ -8615,6 +4134,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -8623,104 +4143,55 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz", - "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==", + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, "dependencies": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" + "is-core-module": "^2.13.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/eslint-plugin-testing-library": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.3.1.tgz", - "integrity": "sha512-OfF4dlG/q6ck6DL3P8Z0FPdK0dU5K57gsBu7eUcaVbwYKaNzjgejnXiM9CCUevppORkvfek+9D3Uj/9ZZ8Vz8g==", - "dependencies": { - "@typescript-eslint/utils": "^5.13.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0", - "npm": ">=6" - }, - "peerDependencies": { - "eslint": "^7.5.0 || ^8.0.0" - } - }, "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "engines": { - "node": ">=10" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint-webpack-plugin": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.1.1.tgz", - "integrity": "sha512-xSucskTN9tOkfW7so4EaiFIkulWLXwCB/15H917lR6pTv0Zot6/fetFucmENRb7J5whVSFKIvwnrnsa78SG2yg==", - "dependencies": { - "@types/eslint": "^7.28.2", - "jest-worker": "^27.3.1", - "micromatch": "^4.0.4", - "normalize-path": "^3.0.0", - "schema-utils": "^3.1.1" - }, - "engines": { - "node": ">= 12.13.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0", - "webpack": "^5.0.0" + "url": "https://opencollective.com/eslint" } }, "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" }, @@ -8731,15 +4202,11 @@ "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==" - }, "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" @@ -8755,6 +4222,7 @@ "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" }, @@ -8765,12 +4233,14 @@ "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==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/eslint/node_modules/globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -8785,25 +4255,16 @@ "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/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "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" }, @@ -8815,6 +4276,7 @@ "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" }, @@ -8823,34 +4285,27 @@ } }, "node_modules/espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, "dependencies": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.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==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "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" }, @@ -8862,6 +4317,7 @@ "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" }, @@ -8873,6 +4329,7 @@ "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" } @@ -8880,207 +4337,34 @@ "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "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": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "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==", - "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=", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", - "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", - "dependencies": { - "@jest/types": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/express": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz", - "integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.19.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.4.2", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~1.1.2", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.1.2", - "fresh": "0.5.2", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.9.7", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.17.2", - "serve-static": "1.14.2", - "setprototypeof": "1.2.0", - "statuses": "~1.5.0", - "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": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" - }, - "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==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "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==", - "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/extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", - "dev": true, - "dependencies": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - } - }, - "node_modules/extract-zip/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/extract-zip/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/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "optional": true + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "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==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -9096,6 +4380,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -9106,53 +4391,29 @@ "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==" + "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": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + "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==", - "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==", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", "dev": true, "dependencies": { - "pend": "~1.2.0" + "reusify": "^1.0.4" } }, "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" }, @@ -9160,29 +4421,11 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, "node_modules/filelist": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz", - "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==", + "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" } @@ -9191,14 +4434,16 @@ "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.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "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" }, @@ -9206,18 +4451,11 @@ "node": ">=10" } }, - "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", - "engines": { - "node": ">= 0.4.0" - } - }, "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==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -9225,52 +4463,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "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==", - "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=" - }, - "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==", - "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-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -9280,6 +4472,7 @@ "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" @@ -9292,128 +4485,39 @@ } }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", + "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "dev": true, "dependencies": { - "flatted": "^3.1.0", + "flatted": "^3.2.9", + "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==" + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true }, - "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.1.tgz", - "integrity": "sha512-x1wumpHOEf4gDROmKTaB6i4/Q6H3LwmjVO7fIX47vBwlZbtPjU33hgoMuD/Q/y6SU8bnuYSoN6ZQOLshGp0T/g==", + "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": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "engines": { - "node": ">=10", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "eslint": ">= 6", - "typescript": ">= 2.7", - "vue-template-compiler": "*", - "webpack": ">= 4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "vue-template-compiler": { - "optional": true - } + "is-callable": "^1.1.3" } }, - "node_modules/fork-ts-checker-webpack-plugin/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/fork-ts-checker-webpack-plugin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/fork-ts-checker-webpack-plugin/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/fork-ts-checker-webpack-plugin/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/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -9424,132 +4528,17 @@ "node": ">=10" } }, - "node_modules/fork-ts-checker-webpack-plugin/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "dependencies": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "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": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", - "engines": { - "node": ">= 0.6" - } - }, - "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==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "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==" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9560,19 +4549,36 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" + "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" } @@ -9581,26 +4587,21 @@ "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==", + "dev": true, "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.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9609,31 +4610,14 @@ "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" - }, - "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==", - "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==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true }, "node_modules/get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -9646,14 +4630,15 @@ } }, "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, @@ -9668,6 +4653,7 @@ "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" }, @@ -9675,134 +4661,20 @@ "node": ">=10.13.0" } }, - "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==" - }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "optional": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/global-dirs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", - "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", - "dev": true, - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/global-modules": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "dependencies": { - "global-prefix": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/global-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "dependencies": { - "ini": "^1.3.5", - "kind-of": "^6.0.2", - "which": "^1.3.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/global-tunnel-ng": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz", - "integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==", - "dev": true, - "optional": true, - "dependencies": { - "encodeurl": "^1.0.2", - "lodash": "^4.17.10", - "npm-conf": "^1.1.3", - "tunnel": "^0.0.6" - }, - "engines": { - "node": ">=0.10" - } - }, "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==", + "dev": true, "engines": { "node": ">=4" } }, "node_modules/globalthis": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz", - "integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", "dev": true, - "optional": true, "dependencies": { "define-properties": "^1.1.3" }, @@ -9813,109 +4685,35 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "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": { - "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" + "get-intrinsic": "^1.1.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", - "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^0.14.0", - "@szmarczak/http-timer": "^1.1.2", - "cacheable-request": "^6.0.0", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^4.1.0", - "lowercase-keys": "^1.0.1", - "mimic-response": "^1.0.1", - "p-cancelable": "^1.0.0", - "to-readable-stream": "^1.0.0", - "url-parse-lax": "^3.0.0" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/got/node_modules/get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=6" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" - }, - "node_modules/graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "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==", - "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==" - }, - "node_modules/harmony-reflect": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", - "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "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" } @@ -9923,17 +4721,30 @@ "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "engines": { "node": ">=4" } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, "dependencies": { - "get-intrinsic": "^1.1.1" + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9943,6 +4754,7 @@ "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" }, @@ -9954,6 +4766,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -9964,29 +4777,33 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-yarn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", - "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "bin": { - "he": "bin/he" - } - }, - "node_modules/history": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", - "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", "dependencies": { - "@babel/runtime": "^7.7.6" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-to-hyperscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", + "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==", + "dependencies": { + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "property-information": "^5.3.0", + "space-separated-tokens": "^1.0.0", + "style-to-object": "^0.3.0", + "unist-util-is": "^4.0.0", + "web-namespaces": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/hoist-non-react-statics": { @@ -10002,100 +4819,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/hoopy": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", - "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", - "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.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "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==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", - "dependencies": { - "whatwg-encoding": "^1.0.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" - }, - "node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -10104,150 +4827,15 @@ "void-elements": "3.1.0" } }, - "node_modules/html-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", - "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "webpack": "^5.20.0" - } - }, - "node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "dev": true - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" - }, - "node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.6.tgz", - "integrity": "sha512-vDlkRPDJn93swjcjqMSaGSPABbIarsr1TLAui/gLDXzV5VsJNdXNzMYDyNBLQkjWQCJ1uizu8T2oDMhmGt0PRA==" - }, - "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==", - "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": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dependencies": { - "@tootallnate/once": "1", - "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==", - "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/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==", - "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==", - "engines": { - "node": ">=10.17.0" - } + "node_modules/humanize-duration": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.30.0.tgz", + "integrity": "sha512-NxpT0fhQTFuMTLnuu1Xp+ozNpYirQnbV3NlOjEKBYlE3uvMRu3LDuq8EPc3gVXxVYnchQfqVM4/+T9iwHPLLeA==" }, "node_modules/i18next": { - "version": "21.6.16", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz", - "integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==", + "version": "21.10.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", + "integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==", "funding": [ { "type": "individual", @@ -10267,114 +4855,36 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", - "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.8.tgz", + "integrity": "sha512-Svm+MduCElO0Meqpj1kJAriTC6OhI41VhlT/A0UPjGoPZBhAHIaGE5EfsHlTpgdH09UVX7rcc72pSDDBeKSQQA==", "dependencies": { - "@babel/runtime": "^7.14.6" + "@babel/runtime": "^7.19.0" } }, "node_modules/i18next-http-backend": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.0.tgz", - "integrity": "sha512-wsvx7E/CT1pHmBM99Vu57YLJpsrHbVjxGxf25EIJ/6oTjsvCkZZ6c3SA4TejcK5jIHfv9oLxQX8l+DFKZHZ0Gg==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.5.tgz", + "integrity": "sha512-tLuHWuLWl6CmS07o+UB6EcQCaUjrZ1yhdseIN7sfq0u7phsMePJ8pqlGhIAdRDPF/q7ooyo5MID5DRFBCH+x5w==", "dependencies": { "cross-fetch": "3.1.5" } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "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==", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/idb": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/idb/-/idb-6.1.5.tgz", - "integrity": "sha512-IJtugpKkiVXQn5Y+LteyBCNk1N8xpGV3wWZk9EVtZWH8DYkjBn0bX1XnGP9RkyZF0sAcywa6unHqSWKe7q4LGw==" - }, - "node_modules/identity-obj-proxy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", - "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=", - "dependencies": { - "harmony-reflect": "^1.4.6" - }, - "engines": { - "node": ">=4" - } - }, - "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" - } - ], - "optional": true + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true }, "node_modules/ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "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/immer": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", - "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -10390,37 +4900,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-lazy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", - "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "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": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -10428,7 +4912,8 @@ "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -10437,43 +4922,89 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" }, "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" }, "engines": { "node": ">= 0.4" } }, - "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==", - "engines": { - "node": ">= 10" + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "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=" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "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" }, @@ -10481,21 +5012,11 @@ "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" @@ -10507,10 +5028,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, "node_modules/is-callable": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", - "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", + "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" }, @@ -10518,24 +5062,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", - "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10545,6 +5077,7 @@ "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" }, @@ -10555,80 +5088,44 @@ "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==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "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=", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "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-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "engines": { - "node": ">=6" - } - }, - "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-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", "dev": true, "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" + "call-bind": "^1.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "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": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=" - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -10636,22 +5133,59 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-npm": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz", - "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==", + "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==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", "dev": true, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "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==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -10660,6 +5194,7 @@ "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" }, @@ -10673,7 +5208,8 @@ "node_modules/is-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -10688,25 +5224,18 @@ } }, "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==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" - }, "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" @@ -10721,23 +5250,26 @@ "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/is-root": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", - "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", - "engines": { - "node": ">=6" + "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.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -10749,6 +5281,7 @@ "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" }, @@ -10760,6 +5293,7 @@ "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" }, @@ -10774,6 +5308,7 @@ "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" }, @@ -10784,15 +5319,35 @@ "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=" + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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" }, @@ -10800,142 +5355,54 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-yarn-global": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", - "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", - "dev": true - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "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, - "engines": { - "node": ">= 8.0.0" + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" }, "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "url": "https://github.com/sponsors/ljharb" } }, + "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz", - "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==", + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "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-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", - "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" } }, "node_modules/jake": { - "version": "10.8.5", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", - "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dev": true, "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", - "filelist": "^1.0.1", - "minimatch": "^3.0.4" + "filelist": "^1.0.4", + "minimatch": "^3.1.2" }, "bin": { "jake": "bin/cli.js" @@ -10948,6 +5415,7 @@ "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" }, @@ -10958,15 +5426,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jake/node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" - }, "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" @@ -10982,6 +5446,7 @@ "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" }, @@ -10992,12 +5457,14 @@ "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==" + "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" } @@ -11006,1667 +5473,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", - "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", - "dependencies": { - "@jest/core": "^27.5.1", - "import-local": "^3.0.2", - "jest-cli": "^27.5.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", - "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", - "dependencies": { - "@jest/types": "^27.5.1", - "execa": "^5.0.0", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-circus": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", - "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-circus/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/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-circus/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/jest-circus/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/jest-circus/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-circus/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", - "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", - "dependencies": { - "@jest/core": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "prompts": "^2.0.1", - "yargs": "^16.2.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-cli/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/jest-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-cli/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/jest-cli/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/jest-cli/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-config": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", - "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", - "dependencies": { - "@babel/core": "^7.8.0", - "@jest/test-sequencer": "^27.5.1", - "@jest/types": "^27.5.1", - "babel-jest": "^27.5.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.1", - "graceful-fs": "^4.2.9", - "jest-circus": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-jasmine2": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/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/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-config/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/jest-config/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/jest-config/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-config/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-diff/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/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-diff/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/jest-diff/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/jest-diff/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-docblock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", - "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-each": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", - "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", - "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-each/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/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-each/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/jest-each/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/jest-each/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-each/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-environment-jsdom": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", - "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1", - "jsdom": "^16.6.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", - "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", - "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", - "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/graceful-fs": "^4.1.2", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^27.5.1", - "jest-serializer": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "micromatch": "^4.0.4", - "walker": "^1.0.7" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-jasmine2": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", - "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/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/jest-jasmine2/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-jasmine2/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/jest-jasmine2/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/jest-jasmine2/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-jasmine2/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-leak-detector": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", - "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", - "dependencies": { - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-matcher-utils/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/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-matcher-utils/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/jest-matcher-utils/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/jest-matcher-utils/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-matcher-utils/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", - "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^27.5.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-message-util/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/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-message-util/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/jest-message-util/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/jest-message-util/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-mock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", - "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", - "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", - "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", - "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", - "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", - "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", - "dependencies": { - "@jest/types": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-snapshot": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-resolve/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/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-resolve/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/jest-resolve/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/jest-resolve/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-resolve/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runner": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", - "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-leak-detector": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "source-map-support": "^0.5.6", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-runner/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/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-runner/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/jest-runner/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/jest-runner/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runner/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-runtime/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/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-runtime/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/jest-runtime/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/jest-runtime/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runtime/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-serializer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", - "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", - "dependencies": { - "@types/node": "*", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "dependencies": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.2" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-snapshot/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/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-snapshot/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/jest-snapshot/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/jest-snapshot/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-snapshot/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-util/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/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-util/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/jest-util/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/jest-util/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", - "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", - "dependencies": { - "@jest/types": "^27.5.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "leven": "^3.1.0", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-validate/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/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-validate/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/jest-validate/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/jest-validate/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watch-typeahead": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.0.0.tgz", - "integrity": "sha512-jxoszalAb394WElmiJTFBMzie/RDCF+W7Q29n5LzOPtcoQoHWfdUtHFkbhgf5NwWe8uMOxvKb/g7ea7CshfkTw==", - "dependencies": { - "ansi-escapes": "^4.3.1", - "chalk": "^4.0.0", - "jest-regex-util": "^27.0.0", - "jest-watcher": "^27.0.0", - "slash": "^4.0.0", - "string-length": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "jest": "^27.0.0" - } - }, - "node_modules/jest-watch-typeahead/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==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/jest-watch-typeahead/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/jest-watch-typeahead/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-watch-typeahead/node_modules/char-regex": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", - "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/jest-watch-typeahead/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/jest-watch-typeahead/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/jest-watch-typeahead/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watch-typeahead/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watch-typeahead/node_modules/string-length": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", - "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", - "dependencies": { - "char-regex": "^2.0.0", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watch-typeahead/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==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/jest-watch-typeahead/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watcher": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", - "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", - "dependencies": { - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "jest-util": "^27.5.1", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-watcher/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/jest-watcher/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/jest-watcher/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/jest-watcher/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/jest-watcher/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watcher/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" }, @@ -12675,13 +5482,14 @@ } }, "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "supports-color": "^7.0.0" }, "engines": { "node": ">= 10.13.0" @@ -12691,41 +5499,27 @@ "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==", + "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": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/joi": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", - "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.0", - "@sideway/pinpoint": "^2.0.0" + "node": ">=8" } }, "node_modules/js-base64": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", - "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==" + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", + "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -12733,90 +5527,22 @@ "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==", + "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": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", - "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", - "dependencies": { - "abab": "^2.0.5", - "acorn": "^8.2.4", - "acorn-globals": "^6.0.0", - "cssom": "^0.4.4", - "cssstyle": "^2.3.0", - "data-urls": "^2.0.0", - "decimal.js": "^10.2.1", - "domexception": "^2.0.1", - "escodegen": "^2.0.0", - "form-data": "^3.0.0", - "html-encoding-sniffer": "^2.0.1", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "6.0.1", - "saxes": "^5.0.1", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^2.0.0", - "webidl-conversions": "^6.1.0", - "whatwg-encoding": "^1.0.5", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.5.0", - "ws": "^7.4.6", - "xml-name-validator": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", - "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -12825,16 +5551,11 @@ } }, "node_modules/json-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "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==" - }, "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", @@ -12843,29 +5564,26 @@ "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==" + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true }, "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==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "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": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" - }, - "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, - "optional": true + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -12877,6 +5595,7 @@ "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" }, @@ -12885,93 +5604,61 @@ } }, "node_modules/jsonpointer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.0.tgz", - "integrity": "sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/jsx-ast-utils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz", - "integrity": "sha512-HDAyJ4MNQBboGpUnHAVUNJs6X0lh058s6FuixsFGP7MgJYpD6Vasd6nzSG5iIfXu1zAYlHJ/zsOKNlrenTUBnw==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, "dependencies": { - "array-includes": "^3.1.4", - "object.assign": "^4.1.2" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, "engines": { "node": ">=4.0" } }, "node_modules/keyv": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", - "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "dependencies": { - "json-buffer": "3.0.0" - } - }, - "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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "engines": { - "node": ">=6" - } - }, - "node_modules/klona": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", - "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", - "engines": { - "node": ">= 8" + "json-buffer": "3.0.1" } }, "node_modules/language-subtag-registry": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", - "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==" + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true }, "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=", - "dependencies": { - "language-subtag-registry": "~0.3.2" - } - }, - "node_modules/latest-version": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", - "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "dependencies": { - "package-json": "^6.3.0" + "language-subtag-registry": "^0.3.20" }, "engines": { - "node": ">=8" + "node": ">=0.10" } }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, "engines": { "node": ">=6" } @@ -12980,6 +5667,7 @@ "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" @@ -12988,44 +5676,16 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", - "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==" }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", - "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "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" }, @@ -13039,32 +5699,26 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "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==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -13077,131 +5731,178 @@ "loose-envify": "cli.js" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lowercase-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true, - "engines": { - "node": ">=0.10.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==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "yallist": "^3.0.2" } }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, "dependencies": { "sourcemap-codec": "^1.4.8" } }, - "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==", + "node_modules/mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" + "unist-util-visit": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" }, - "engines": { - "node": ">=10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mdn-data": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", - "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" - }, - "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=", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.1.tgz", - "integrity": "sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==", + "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", "dependencies": { - "fs-monkey": "1.0.3" + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" }, - "engines": { - "node": ">= 4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/merge-descriptors": { + "node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz", + "integrity": "sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" }, "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==" + "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/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", - "engines": { - "node": ">= 0.6" + "node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" } }, "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" @@ -13210,129 +5911,11 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.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==", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz", - "integrity": "sha512-ndG8nxCEnAemsg4FSgS+yNyHKgkTB4nPKqCOgh65j3/30qqC5RaSQQXMm++Y6sb6E1zRSxPkztj9fqxhS1Eo6w==", - "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/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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/mini-css-extract-plugin/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==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/mini-css-extract-plugin/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==" - }, - "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "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==" - }, "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" }, @@ -13341,19 +5924,12 @@ } }, "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "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==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ms": { @@ -13361,22 +5937,17 @@ "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.4", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz", - "integrity": "sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw==", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -13387,36 +5958,8 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" - }, - "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==", - "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==" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "optional": true + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/node-fetch": { "version": "2.6.7", @@ -13437,112 +5980,25 @@ } } }, - "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==", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=" - }, "node_modules/node-releases": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.3.tgz", - "integrity": "sha512-maHFz6OLqYxz+VQyCAtA3PTX4UP/53pa05fyDNc9CwjvJ0yEh6+xBwKsgCxMNhS8taUKBFYxfuiaD9U/55iFaw==" - }, - "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/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-conf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", - "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", - "dev": true, - "optional": true, - "dependencies": { - "config-chain": "^1.1.11", - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "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==", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nth-check": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", - "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/nwsapi": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", - "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==" + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true }, "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=", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "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==", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", - "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "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" } @@ -13551,18 +6007,20 @@ "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.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, "engines": { @@ -13573,26 +6031,28 @@ } }, "node_modules/object.entries": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", - "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" } }, "node_modules/object.fromentries": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz", - "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -13601,42 +6061,40 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.getownpropertydescriptors": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", - "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" } }, "node_modules/object.hasown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz", - "integrity": "sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", + "integrity": "sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==", + "dev": true, "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -13645,97 +6103,37 @@ "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==" - }, - "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "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==", - "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=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "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==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", - "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/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", "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" + "type-check": "^0.4.0" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/p-cancelable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "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" }, @@ -13750,6 +6148,7 @@ "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" }, @@ -13760,50 +6159,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.1.tgz", - "integrity": "sha512-e2xXGNhZOZ0lfgR9kL34iGlU8N/KO0xZnQxVEwdeOvpqNDQfdnxIYizvWtK8RglUa3bGqI8g0R/BdfzLMxRkiA==", - "dependencies": { - "@types/retry": "^0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "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==", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", - "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", - "dev": true, - "dependencies": { - "got": "^9.6.0", - "registry-auth-token": "^4.0.0", - "registry-url": "^5.0.0", - "semver": "^6.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13815,6 +6170,23 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13832,32 +6204,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "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" } @@ -13865,7 +6216,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=", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13874,6 +6226,7 @@ "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" } @@ -13883,11 +6236,6 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, - "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": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13896,26 +6244,17 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", - "dev": true - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -13923,197 +6262,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", - "engines": { - "node": ">= 6" - } - }, - "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==", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/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==", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/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==", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/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==", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/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==", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/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==", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "engines": { - "node": ">=4" - } - }, - "node_modules/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-83vX4eYdQp3vP9SxuYgEM/G/pJQqLUz/V/xzPrzruLs7fz7jxGQ1msZ/mg1nwZxUSuOp4sb+/bEIbRrbzZRxDA==", - "dev": true, - "dependencies": { - "base64-js": "^1.5.1", - "xmlbuilder": "^9.0.7" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/plist/node_modules/xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/portfinder": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", - "integrity": "sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==", - "dependencies": { - "async": "^2.6.2", - "debug": "^3.1.1", - "mkdirp": "^0.5.5" - }, - "engines": { - "node": ">= 0.12.0" - } - }, - "node_modules/portfinder/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/postcss": { - "version": "8.4.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz", - "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -14122,10 +6275,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.1", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -14133,1179 +6290,42 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-attribute-case-insensitive": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.0.tgz", - "integrity": "sha512-b4g9eagFGq9T5SWX4+USfVyjIb3liPnjhHHRMP7FMB2kFVpYyfEscV0wP3eaXhKlcHKUut8lt5BGoeylWA/dBQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.2" - }, - "peerDependencies": { - "postcss": "^8.0.2" - } - }, - "node_modules/postcss-browser-comments": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", - "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "browserslist": ">=4", - "postcss": ">=8" - } - }, - "node_modules/postcss-calc": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", - "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", - "dependencies": { - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0" - }, - "peerDependencies": { - "postcss": "^8.2.2" - } - }, - "node_modules/postcss-clamp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", - "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=7.6.0" - }, - "peerDependencies": { - "postcss": "^8.4.6" - } - }, - "node_modules/postcss-color-functional-notation": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.2.tgz", - "integrity": "sha512-DXVtwUhIk4f49KK5EGuEdgx4Gnyj6+t2jBSEmxvpIK9QI40tWrpS2Pua8Q7iIZWBrki2QOaeUdEaLPPa91K0RQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-hex-alpha": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.3.tgz", - "integrity": "sha512-fESawWJCrBV035DcbKRPAVmy21LpoyiXdPTuHUfWJ14ZRjY7Y7PA6P4g8z6LQGYhU1WAxkTxjIjurXzoe68Glw==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-rebeccapurple": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.0.2.tgz", - "integrity": "sha512-SFc3MaocHaQ6k3oZaFwH8io6MdypkUtEy/eXzXEB1vEQlO3S3oDc/FSZA8AsS04Z25RirQhlDlHLh3dn7XewWw==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.3" - } - }, - "node_modules/postcss-colormin": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", - "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", - "dependencies": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0", - "colord": "^2.9.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-convert-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.0.tgz", - "integrity": "sha512-GkyPbZEYJiWtQB0KZ0X6qusqFHUepguBCNFi9t5JJc7I2OTXG7C0twbTLvCfaKOLl3rSXmpAwV7W5txd91V84g==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-custom-media": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.0.tgz", - "integrity": "sha512-FvO2GzMUaTN0t1fBULDeIvxr5IvbDXcIatt6pnJghc736nqNgsGao5NT+5+WVLAQiTt6Cb3YUms0jiPaXhL//g==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-custom-properties": { - "version": "12.1.7", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.7.tgz", - "integrity": "sha512-N/hYP5gSoFhaqxi2DPCmvto/ZcRDVjE3T1LiAMzc/bg53hvhcHOLpXOHb526LzBBp5ZlAUhkuot/bfpmpgStJg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-selectors": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.0.tgz", - "integrity": "sha512-/1iyBhz/W8jUepjGyu7V1OPcGbc636snN1yXEQCinb6Bwt7KxsiU7/bLQlp8GwAXzCh7cobBU5odNn/2zQWR8Q==", - "dependencies": { - "postcss-selector-parser": "^6.0.4" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "postcss": "^8.1.2" - } - }, - "node_modules/postcss-dir-pseudo-class": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.4.tgz", - "integrity": "sha512-I8epwGy5ftdzNWEYok9VjW9whC4xnelAtbajGv4adql4FIF09rnrxnA9Y8xSHN47y7gqFIv10C5+ImsLeJpKBw==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-discard-comments": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz", - "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-discard-duplicates": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", - "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-discard-empty": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", - "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-discard-overridden": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", - "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-double-position-gradients": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.1.tgz", - "integrity": "sha512-jM+CGkTs4FcG53sMPjrrGE0rIvLDdCrqMzgDC5fLI7JHDO7o6QG8C5TQBtExb13hdBdoH9C2QVbG4jo2y9lErQ==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-env-function": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", - "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-flexbugs-fixes": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", - "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", - "peerDependencies": { - "postcss": "^8.1.4" - } - }, - "node_modules/postcss-focus-visible": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", - "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-within": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", - "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-font-variant": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", - "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-gap-properties": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.3.tgz", - "integrity": "sha512-rPPZRLPmEKgLk/KlXMqRaNkYTUpE7YC+bOIQFN5xcu1Vp11Y4faIXv6/Jpft6FMnl6YRxZqDZG0qQOW80stzxQ==", - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-image-set-function": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.6.tgz", - "integrity": "sha512-KfdC6vg53GC+vPd2+HYzsZ6obmPqOk6HY09kttU19+Gj1nC3S3XBVEXDHxkhxTohgZqzbUb94bKXvKDnYWBm/A==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-initial": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", - "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-lab-function": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.0.tgz", - "integrity": "sha512-Zb1EO9DGYfa3CP8LhINHCcTTCTLI+R3t7AX2mKsDzdgVQ/GkCpHOTgOr6HBHslP7XDdVbqgHW5vvRPMdVANQ8w==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "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": "6.2.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", - "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", - "dependencies": { - "cosmiconfig": "^7.0.0", - "klona": "^2.0.5", - "semver": "^7.3.5" - }, - "engines": { - "node": ">= 12.13.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/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-loader/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-logical": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", - "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-media-minmax": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", - "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-merge-longhand": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.4.tgz", - "integrity": "sha512-hbqRRqYfmXoGpzYKeW0/NCZhvNyQIlQeWVSao5iKWdyx7skLvCfQFGIUsP9NUs3dSbPac2IC4Go85/zG+7MlmA==", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^5.1.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-merge-rules": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz", - "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==", - "dependencies": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^3.1.0", - "postcss-selector-parser": "^6.0.5" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-minify-font-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", - "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-minify-gradients": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", - "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", - "dependencies": { - "colord": "^2.9.1", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-minify-params": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.2.tgz", - "integrity": "sha512-aEP+p71S/urY48HWaRHasyx4WHQJyOYaKpQ6eXl8k0kxg66Wt/30VR6/woh8THgcpRbonJD5IeD+CzNhPi1L8g==", - "dependencies": { - "browserslist": "^4.16.6", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-minify-selectors": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz", - "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==", - "dependencies": { - "postcss-selector-parser": "^6.0.5" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "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==", - "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==", - "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==", - "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==", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "dependencies": { - "postcss-selector-parser": "^6.0.6" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nesting": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.1.4.tgz", - "integrity": "sha512-2ixdQ59ik/Gt1+oPHiI1kHdwEI8lLKEmui9B1nl6163ANLC+GewQn7fXMxJF2JSb4i2MKL96GU8fIiQztK4TTA==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-normalize": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", - "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", - "dependencies": { - "@csstools/normalize.css": "*", - "postcss-browser-comments": "^4", - "sanitize.css": "*" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "browserslist": ">= 4", - "postcss": ">= 8" - } - }, - "node_modules/postcss-normalize-charset": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", - "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-display-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", - "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-positions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.0.tgz", - "integrity": "sha512-8gmItgA4H5xiUxgN/3TVvXRoJxkAWLW6f/KKhdsH03atg0cB8ilXnrB5PpSshwVu/dD2ZsRFQcR1OEmSBDAgcQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-repeat-style": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.0.tgz", - "integrity": "sha512-IR3uBjc+7mcWGL6CtniKNQ4Rr5fTxwkaDHwMBDGGs1x9IVRkYIT/M4NelZWkAOBdV6v3Z9S46zqaKGlyzHSchw==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-string": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", - "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-timing-functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", - "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-unicode": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", - "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", - "dependencies": { - "browserslist": "^4.16.6", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-url": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", - "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", - "dependencies": { - "normalize-url": "^6.0.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-normalize-whitespace": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", - "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-opacity-percentage": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.2.tgz", - "integrity": "sha512-lyUfF7miG+yewZ8EAk9XUBIlrHyUE6fijnesuz+Mj5zrIHIEw6KcIZSOk/elVMqzLvREmXB83Zi/5QpNRYd47w==", - "funding": [ - { - "type": "kofi", - "url": "https://ko-fi.com/mrcgrtz" - }, - { - "type": "liberapay", - "url": "https://liberapay.com/mrcgrtz" - } - ], - "engines": { - "node": "^12 || ^14 || >=16" - } - }, - "node_modules/postcss-ordered-values": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.1.tgz", - "integrity": "sha512-7lxgXF0NaoMIgyihL/2boNAEZKiW0+HkMhdKMTD93CjW8TdCy2hSdj8lsAo+uwm7EDG16Da2Jdmtqpedl0cMfw==", - "dependencies": { - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-overflow-shorthand": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.3.tgz", - "integrity": "sha512-CxZwoWup9KXzQeeIxtgOciQ00tDtnylYIlJBBODqkgS/PU2jISuWOL/mYLHmZb9ZhZiCaNKsCRiLp22dZUtNsg==", - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-page-break": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", - "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "peerDependencies": { - "postcss": "^8" - } - }, - "node_modules/postcss-place": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.4.tgz", - "integrity": "sha512-MrgKeiiu5OC/TETQO45kV3npRjOFxEHthsqGtkh3I1rPbZSbXGD/lZVi9j13cYh+NA8PIAPyk6sGjT9QbRyvSg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-preset-env": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.4.3.tgz", - "integrity": "sha512-dlPA65g9KuGv7YsmGyCKtFkZKCPLkoVMUE3omOl6yM+qrynVHxFvf0tMuippIrXB/sB/MyhL1FgTIbrO+qMERg==", - "dependencies": { - "@csstools/postcss-color-function": "^1.0.3", - "@csstools/postcss-font-format-keywords": "^1.0.0", - "@csstools/postcss-hwb-function": "^1.0.0", - "@csstools/postcss-ic-unit": "^1.0.0", - "@csstools/postcss-is-pseudo-class": "^2.0.1", - "@csstools/postcss-normalize-display-values": "^1.0.0", - "@csstools/postcss-oklab-function": "^1.0.2", - "@csstools/postcss-progressive-custom-properties": "^1.3.0", - "autoprefixer": "^10.4.4", - "browserslist": "^4.20.2", - "css-blank-pseudo": "^3.0.3", - "css-has-pseudo": "^3.0.4", - "css-prefers-color-scheme": "^6.0.3", - "cssdb": "^6.5.0", - "postcss-attribute-case-insensitive": "^5.0.0", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^4.2.2", - "postcss-color-hex-alpha": "^8.0.3", - "postcss-color-rebeccapurple": "^7.0.2", - "postcss-custom-media": "^8.0.0", - "postcss-custom-properties": "^12.1.5", - "postcss-custom-selectors": "^6.0.0", - "postcss-dir-pseudo-class": "^6.0.4", - "postcss-double-position-gradients": "^3.1.1", - "postcss-env-function": "^4.0.6", - "postcss-focus-visible": "^6.0.4", - "postcss-focus-within": "^5.0.4", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^3.0.3", - "postcss-image-set-function": "^4.0.6", - "postcss-initial": "^4.0.1", - "postcss-lab-function": "^4.1.2", - "postcss-logical": "^5.0.4", - "postcss-media-minmax": "^5.0.0", - "postcss-nesting": "^10.1.3", - "postcss-opacity-percentage": "^1.1.2", - "postcss-overflow-shorthand": "^3.0.3", - "postcss-page-break": "^3.0.4", - "postcss-place": "^7.0.4", - "postcss-pseudo-class-any-link": "^7.1.1", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^5.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-pseudo-class-any-link": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.2.tgz", - "integrity": "sha512-76XzEQv3g+Vgnz3tmqh3pqQyRojkcJ+pjaePsyhcyf164p9aZsu3t+NWxkZYbcHLK1ju5Qmalti2jPI5IWCe5w==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-reduce-initial": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", - "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", - "dependencies": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-reduce-transforms": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", - "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-replace-overflow-wrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", - "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "peerDependencies": { - "postcss": "^8.0.3" - } - }, - "node_modules/postcss-selector-not": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-5.0.0.tgz", - "integrity": "sha512-/2K3A4TCP9orP4TNS7u3tGdRFVKqz/E6pX3aGnriPG0jU78of8wsUcqE4QAhWEU0d+WnMSF93Ah3F//vUtK+iQ==", - "dependencies": { - "balanced-match": "^1.0.0" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-svgo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", - "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^2.7.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/postcss-svgo/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/postcss-svgo/node_modules/css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", - "dependencies": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/postcss-svgo/node_modules/mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "node_modules/postcss-svgo/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss-svgo/node_modules/svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", - "dependencies": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", - "css-select": "^4.1.3", - "css-tree": "^1.1.3", - "csso": "^4.2.0", - "picocolors": "^1.0.0", - "stable": "^0.1.8" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/postcss-unique-selectors": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", - "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", - "dependencies": { - "postcss-selector-parser": "^6.0.5" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "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/prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, "engines": { - "node": ">=4" + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "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==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, "engines": { - "node": ">=6" + "node": "^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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==" - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", - "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", - "dependencies": { - "asap": "~2.0.6" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -15321,92 +6341,32 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", - "dev": true, - "optional": true - }, - "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==", + "node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "xtend": "^4.0.0" }, - "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==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/pupa": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", - "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", - "dev": true, - "dependencies": { - "escape-goat": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, - "node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", - "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", @@ -15422,102 +6382,19 @@ } ] }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "dependencies": { - "performance-now": "^2.1.0" - } - }, "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==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz", - "integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "1.8.1", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "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==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.0.0.tgz", - "integrity": "sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -15525,152 +6402,24 @@ "node": ">=0.10.0" } }, - "node_modules/react-app-polyfill": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", - "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", - "dependencies": { - "core-js": "^3.19.2", - "object-assign": "^4.1.1", - "promise": "^8.1.0", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.9", - "whatwg-fetch": "^3.6.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/react-dev-utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", - "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", - "dependencies": { - "@babel/code-frame": "^7.16.0", - "address": "^1.1.2", - "browserslist": "^4.18.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "detect-port-alt": "^1.1.6", - "escape-string-regexp": "^4.0.0", - "filesize": "^8.0.6", - "find-up": "^5.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.0", - "global-modules": "^2.0.0", - "globby": "^11.0.4", - "gzip-size": "^6.0.0", - "immer": "^9.0.7", - "is-root": "^2.1.0", - "loader-utils": "^3.2.0", - "open": "^8.4.0", - "pkg-up": "^3.1.0", - "prompts": "^2.4.2", - "react-error-overlay": "^6.0.11", - "recursive-readdir": "^2.2.2", - "shell-quote": "^1.7.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/react-dev-utils/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/react-dev-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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/react-dev-utils/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/react-dev-utils/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/react-dev-utils/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-dev-utils/node_modules/loader-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.0.tgz", - "integrity": "sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/react-dev-utils/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/react-dom": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.0.0.tgz", - "integrity": "sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" + "scheduler": "^0.23.0" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.2.0" } }, - "node_modules/react-error-overlay": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", - "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" - }, "node_modules/react-i18next": { - "version": "11.16.7", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz", - "integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==", + "version": "11.18.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz", + "integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==", "dependencies": { "@babel/runtime": "^7.14.5", - "html-escaper": "^2.0.2", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { @@ -15698,132 +6447,74 @@ } }, "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/react-refresh": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", - "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/react-router": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", - "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "node_modules/react-remark": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-remark/-/react-remark-2.1.0.tgz", + "integrity": "sha512-7dEPxRGQ23sOdvteuRGaQAs9cEOH/BOeCN4CqsJdk3laUDIDYRCWnM6a3z92PzXHUuxIRLXQNZx7SiO0ijUcbw==", "dependencies": { - "history": "^5.2.0" + "rehype-react": "^6.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.0.0", + "unified": "^9.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz", + "integrity": "sha512-vk2y7Dsy8wI02eRRaRmOs9g2o+aE72YCx5q9VasT1N9v+lrdB79tIqrjMfByHiY5+6aYkH2rUa5X839nwWGPDg==", + "dependencies": { + "@remix-run/router": "1.11.0" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", - "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.18.0.tgz", + "integrity": "sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw==", "dependencies": { - "history": "^5.2.0", - "react-router": "6.3.0" + "@remix-run/router": "1.11.0", + "react-router": "6.18.0" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, - "node_modules/react-scripts": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", - "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", - "dependencies": { - "@babel/core": "^7.16.0", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", - "@svgr/webpack": "^5.5.0", - "babel-jest": "^27.4.2", - "babel-loader": "^8.2.3", - "babel-plugin-named-asset-import": "^0.3.8", - "babel-preset-react-app": "^10.0.1", - "bfj": "^7.0.2", - "browserslist": "^4.18.1", - "camelcase": "^6.2.1", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "css-loader": "^6.5.1", - "css-minimizer-webpack-plugin": "^3.2.0", - "dotenv": "^10.0.0", - "dotenv-expand": "^5.1.0", - "eslint": "^8.3.0", - "eslint-config-react-app": "^7.0.1", - "eslint-webpack-plugin": "^3.1.1", - "file-loader": "^6.2.0", - "fs-extra": "^10.0.0", - "html-webpack-plugin": "^5.5.0", - "identity-obj-proxy": "^3.0.0", - "jest": "^27.4.3", - "jest-resolve": "^27.4.2", - "jest-watch-typeahead": "^1.0.0", - "mini-css-extract-plugin": "^2.4.5", - "postcss": "^8.4.4", - "postcss-flexbugs-fixes": "^5.0.2", - "postcss-loader": "^6.2.1", - "postcss-normalize": "^10.0.1", - "postcss-preset-env": "^7.0.1", - "prompts": "^2.4.2", - "react-app-polyfill": "^3.0.0", - "react-dev-utils": "^12.0.1", - "react-refresh": "^0.11.0", - "resolve": "^1.20.0", - "resolve-url-loader": "^4.0.0", - "sass-loader": "^12.3.0", - "semver": "^7.3.5", - "source-map-loader": "^3.0.0", - "style-loader": "^3.3.1", - "tailwindcss": "^3.0.2", - "terser-webpack-plugin": "^5.2.5", - "webpack": "^5.64.4", - "webpack-dev-server": "^4.6.0", - "webpack-manifest-plugin": "^4.0.2", - "workbox-webpack-plugin": "^6.4.1" - }, - "bin": { - "react-scripts": "bin/react-scripts.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - }, - "peerDependencies": { - "react": ">= 16", - "typescript": "^3.2.1 || ^4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/react-scripts/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -15835,137 +6526,18 @@ "react-dom": ">=16.6.0" } }, - "node_modules/read-config-file": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.2.0.tgz", - "integrity": "sha512-gx7Pgr5I56JtYz+WuqEbQHj/xWo+5Vwua2jhb1VwM4Wid5PqYmZ4i00ZB0YEGIfkVBsCv9UrjgyqCiQfS/Oosg==", + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", "dev": true, - "dependencies": { - "dotenv": "^9.0.2", - "dotenv-expand": "^5.1.0", - "js-yaml": "^4.1.0", - "json5": "^2.2.0", - "lazy-val": "^1.0.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/read-config-file/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/read-config-file/node_modules/dotenv": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", - "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-config-file/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/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==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "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/recursive-readdir": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", - "integrity": "sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==", - "dependencies": { - "minimatch": "3.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/recursive-readdir/node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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==" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz", - "integrity": "sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "node_modules/regenerator-transform": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", - "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", - "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==" - }, - "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" }, "engines": { "node": ">= 0.4" @@ -15974,66 +6546,77 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } + "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/regexpu-core": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz", - "integrity": "sha512-CriEZlrKK9VJw/xQGJpQM5rY88BtuL8DM+AEwvcThHilbxiTAy8vq4iJnd2tqq8wLmjbGZzP7ZcKFjbGkmEFrw==", + "node_modules/regenerate-unicode-properties": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "dev": true, "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.0.1", - "regjsgen": "^0.6.0", - "regjsparser": "^0.8.2", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.0.0" + "regenerate": "^1.4.2" }, "engines": { "node": ">=4" } }, - "node_modules/registry-auth-token": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", - "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", "dev": true, "dependencies": { - "rc": "^1.2.8" - }, - "engines": { - "node": ">=6.0.0" + "@babel/runtime": "^7.8.4" } }, - "node_modules/registry-url": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", - "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dev": true, "dependencies": { - "rc": "^1.2.8" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regjsgen": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.6.0.tgz", - "integrity": "sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==" + "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.8.4", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.8.4.tgz", - "integrity": "sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==", + "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" }, @@ -16044,58 +6627,64 @@ "node_modules/regjsparser/node_modules/jsesc": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" } }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "node_modules/rehype-react": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/rehype-react/-/rehype-react-6.2.1.tgz", + "integrity": "sha512-f9KIrjktvLvmbGc7si25HepocOg4z0MuNOtweigKzBcDjiGSTGhyz6VSgaV5K421Cq1O+z4/oxRJ5G9owo0KVg==", "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" + "@mapbox/hast-util-table-cell-style": "^0.2.0", + "hast-to-hyperscript": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "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/remark-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", + "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "dependencies": { + "mdast-util-from-markdown": "^0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-8.1.0.tgz", + "integrity": "sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==", + "dependencies": { + "mdast-util-to-hast": "^10.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "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/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" - }, "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -16106,25 +6695,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/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==", - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -16133,91 +6703,11 @@ "node": ">=4" } }, - "node_modules/resolve-url-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", - "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^7.0.35", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=8.9" - }, - "peerDependencies": { - "rework": "1.0.1", - "rework-visit": "1.0.0" - }, - "peerDependenciesMeta": { - "rework": { - "optional": true - }, - "rework-visit": { - "optional": true - } - } - }, - "node_modules/resolve-url-loader/node_modules/picocolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" - }, - "node_modules/resolve-url-loader/node_modules/postcss": { - "version": "7.0.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", - "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", - "dependencies": { - "picocolors": "^0.2.1", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - } - }, - "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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve.exports": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/responselike": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", - "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", - "dev": true, - "dependencies": { - "lowercase-keys": "^1.0.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "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" @@ -16227,6 +6717,7 @@ "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" }, @@ -16237,103 +6728,27 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/roarr/node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true, - "optional": true - }, "node_modules/rollup": { - "version": "2.70.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.2.tgz", - "integrity": "sha512-EitogNZnfku65I1DD5Mxe8JYRUCy0hkK5X84IlDtUs+O6JRMpRciXTzyCUuX11b5L5pvjH+OmFXiQ3XjabcXgg==", + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "dev": true, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=10.0.0" + "node": ">=14.18.0", + "npm": ">=8.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0" - } - }, - "node_modules/rollup-plugin-terser/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup-plugin-terser/node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/rollup-plugin-terser/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "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", @@ -16352,642 +6767,29 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", "dev": true, "dependencies": { - "tslib": "^1.9.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" }, "engines": { - "npm": ">=2.0.0" - } - }, - "node_modules/rxjs/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/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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "dev": true, - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sanitize.css": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", - "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" - }, - "node_modules/sass-loader": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", - "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", - "dependencies": { - "klona": "^2.0.4", - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 12.13.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", - "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/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, - "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "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==", - "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/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" - }, - "node_modules/selfsigned": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz", - "integrity": "sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==", - "dependencies": { - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, - "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/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", - "dev": true, - "optional": true - }, - "node_modules/semver-diff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", - "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", - "dev": true, - "dependencies": { - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/send": { - "version": "0.17.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", - "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", - "dependencies": { - "debug": "2.6.9", - "depd": "~1.1.2", - "destroy": "~1.0.4", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "1.8.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.3.0", - "range-parser": "~1.2.1", - "statuses": "~1.5.0" - }, - "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==", - "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": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "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==" - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "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": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", - "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==", - "dependencies": { - "ms": "2.0.0" - } - }, - "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": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", - "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": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "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==" - }, - "node_modules/serve-static": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz", - "integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.17.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "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==", - "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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", - "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==" - }, - "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==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "node": ">=0.4" }, "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==" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-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, - "optional": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-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==", - "dev": true, - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/slice-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==", - "dev": true, - "optional": true - }, - "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, - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.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==", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/source-list-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "engines": { - "node": ">=0.10.0" - } - }, - "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": "3.0.1", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.1.tgz", - "integrity": "sha512-Vp1UsfyPvgujKQzi4pyDiTOnE3E4H+yHvkVRN3c/9PJmQS4CQJExvcDvaX/D+RV+xQben9HJ56jMJS3CgUeWyA==", - "dependencies": { - "abab": "^2.0.5", - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "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==", - "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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" - }, - "node_modules/spawn-command": { - "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", - "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==", - "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==", - "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/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" - }, - "node_modules/stable": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" - }, - "node_modules/stack-generator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz", - "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==", - "dependencies": { - "stackframe": "^1.1.1" - } - }, - "node_modules/stack-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/stackframe": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.1.tgz", - "integrity": "sha512-h88QkzREN/hy8eRdyNhhsO7RSJ5oyTqxxmmn0dzBIMUclZsjpfmrsg81vp8mjjAs2vAZ72nyWxRUwSwmh0e4xg==" - }, - "node_modules/stacktrace-gps": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz", - "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==", - "dependencies": { - "source-map": "0.5.6", - "stackframe": "^1.1.1" - } - }, - "node_modules/stacktrace-gps/node_modules/source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stacktrace-js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", - "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", - "dependencies": { - "error-stack-parser": "^2.0.6", - "stack-generator": "^2.0.5", - "stacktrace-gps": "^3.0.4" - } - }, - "node_modules/stat-mode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", - "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", - "engines": { - "node": ">= 0.6" - } - }, - "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==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { + "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", @@ -17003,78 +6805,262 @@ } ] }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string-natural-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", - "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" - }, - "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==", + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/string-width/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/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/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/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true + }, + "node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } }, "node_modules/string.prototype.matchall": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", - "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", + "integrity": "sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1", - "get-intrinsic": "^1.1.1", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.1", + "internal-slot": "^1.0.5", + "regexp.prototype.flags": "^1.5.0", + "set-function-name": "^2.0.0", "side-channel": "^1.0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "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.1.3" + "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.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "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.1.3" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -17084,6 +7070,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", @@ -17097,6 +7084,7 @@ "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" }, @@ -17105,33 +7093,28 @@ } }, "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "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": ">=8" + "node": ">=4" } }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, "engines": { "node": ">=10" } }, - "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==", - "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" }, @@ -17139,51 +7122,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-loader": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", - "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/stylehacks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", - "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "node_modules/style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", "dependencies": { - "browserslist": "^4.16.6", - "postcss-selector-parser": "^6.0.4" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "inline-style-parser": "0.1.1" } }, "node_modules/stylis": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", - "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz", + "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==" }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, + "node_modules/stylis-plugin-rtl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", + "integrity": "sha512-q6xIkri6fBufIO/sV55md2CbgS5c6gg9EhSVATtHHCdOnbN/jcI0u3lYhNVeuI65c4lQPo67g8xmq5jrREvzlg==", "dependencies": { - "debug": "^4.1.0" + "cssjanus": "^2.0.1" }, - "engines": { - "node": ">= 8.0" + "peerDependencies": { + "stylis": "4.x" } }, "node_modules/supports-color": { @@ -17197,37 +7157,6 @@ "node": ">=4" } }, - "node_modules/supports-hyperlinks": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz", - "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/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==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "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", @@ -17239,169 +7168,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" - }, - "node_modules/svgo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", - "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", - "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", - "dependencies": { - "chalk": "^2.4.1", - "coa": "^2.0.2", - "css-select": "^2.0.0", - "css-select-base-adapter": "^0.1.1", - "css-tree": "1.0.0-alpha.37", - "csso": "^4.0.2", - "js-yaml": "^3.13.1", - "mkdirp": "~0.5.1", - "object.values": "^1.1.0", - "sax": "~1.2.4", - "stable": "^0.1.8", - "unquote": "~1.1.1", - "util.promisify": "~1.0.0" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/svgo/node_modules/css-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", - "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^3.2.1", - "domutils": "^1.7.0", - "nth-check": "^1.0.2" - } - }, - "node_modules/svgo/node_modules/css-what": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", - "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/svgo/node_modules/dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "dependencies": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - } - }, - "node_modules/svgo/node_modules/domutils": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", - "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", - "dependencies": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" - }, - "node_modules/svgo/node_modules/nth-check": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", - "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", - "dependencies": { - "boolbase": "~1.0.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" - }, - "node_modules/tailwindcss": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.24.tgz", - "integrity": "sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==", - "dependencies": { - "arg": "^5.0.1", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "lilconfig": "^2.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.12", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.4", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.10", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/tailwindcss/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/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", - "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", - "dev": true, - "dependencies": { - "async-exit-hook": "^2.0.1", - "fs-extra": "^10.0.0" - } - }, "node_modules/tempy": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", @@ -17415,40 +7195,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tempy/node_modules/type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", + "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "dev": true, "dependencies": { - "acorn": "^8.5.0", + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "bin": { @@ -17458,82 +7213,11 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz", - "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==", - "dependencies": { - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.0", - "source-map": "^0.6.1", - "terser": "^5.7.2" - }, - "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/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/terser/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/terser/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, - "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==", - "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=" - }, - "node_modules/throat": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", - "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/throttle-debounce": { "version": "2.3.0", @@ -17543,58 +7227,19 @@ "node": ">=8" } }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, - "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/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" - }, "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": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", "engines": { "node": ">=4" } }, - "node_modules/to-readable-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "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==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -17602,78 +7247,37 @@ "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==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", - "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.1.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "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/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", - "dev": true, - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, - "node_modules/tryer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", - "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" - }, "node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "dev": true, "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "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" }, @@ -17681,52 +7285,11 @@ "json5": "lib/cli.js" } }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "engines": { - "node": ">=4" - } - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "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/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, "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" }, @@ -17734,18 +7297,11 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "engines": { - "node": ">=4" - } - }, "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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, "engines": { "node": ">=10" }, @@ -17753,63 +7309,97 @@ "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==", + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", - "dev": true - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" }, "engines": { - "node": ">=4.2.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "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": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", + "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/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "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" } @@ -17818,6 +7408,7 @@ "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" @@ -17827,25 +7418,45 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", - "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", + "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.0.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", - "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==", + "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/unified": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", + "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, "dependencies": { "crypto-random-string": "^2.0.0" }, @@ -17853,747 +7464,273 @@ "node": ">=8" } }, + "node_modules/unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.1.tgz", + "integrity": "sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==", + "dependencies": { + "unist-util-visit-parents": "^2.0.0" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz", + "integrity": "sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g==", + "dependencies": { + "unist-util-is": "^3.0.0" + } + }, + "node_modules/unist-util-visit-parents/node_modules/unist-util-is": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-3.0.0.tgz", + "integrity": "sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==" + }, "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "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=", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unquote": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=" - }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, "engines": { "node": ">=4", "yarn": "*" } }, - "node_modules/update-notifier": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz", - "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==", + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "dev": true, + "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": { - "boxen": "^5.0.0", - "chalk": "^4.1.0", - "configstore": "^5.0.1", - "has-yarn": "^2.1.0", - "import-lazy": "^2.1.0", - "is-ci": "^2.0.0", - "is-installed-globally": "^0.4.0", - "is-npm": "^5.0.0", - "is-yarn-global": "^0.3.0", - "latest-version": "^5.1.0", - "pupa": "^2.1.1", - "semver": "^7.3.4", - "semver-diff": "^3.1.1", - "xdg-basedir": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/update-notifier/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/update-notifier/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/update-notifier/node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true - }, - "node_modules/update-notifier/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/update-notifier/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/update-notifier/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/update-notifier/node_modules/is-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", - "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", - "dev": true, - "dependencies": { - "ci-info": "^2.0.0" + "escalade": "^3.1.1", + "picocolors": "^1.0.0" }, "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/update-notifier/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" + "update-browserslist-db": "cli.js" }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/update-notifier/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" + "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/url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", - "dev": true, + "node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", "dependencies": { - "prepend-http": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/utf8-byte-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=", - "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=" - }, - "node_modules/util.promisify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", - "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.2", - "has-symbols": "^1.0.1", - "object.getownpropertydescriptors": "^2.1.0" + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", - "engines": { - "node": ">= 0.4.0" + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "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==", + "node_modules/vite": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", + "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, "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==" - }, - "node_modules/v8-to-istanbul": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", - "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0", - "source-map": "^0.7.3" + "vite": "bin/vite.js" }, "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/v8-to-istanbul/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" + "node": "^14.18.0 || >=16.0.0" }, - "engines": { - "node": ">=0.6.0" + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/verror/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=", + "node_modules/vite-plugin-pwa": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.15.2.tgz", + "integrity": "sha512-l1srtaad5NMNrAtAuub6ArTYG5Ci9AwofXXQ6IsbpCMYQ/0HUndwI7RB2x95+1UBFm7VGttQtT7woBlVnNhBRw==", "dev": true, - "optional": true + "dependencies": { + "debug": "^4.3.4", + "fast-glob": "^3.2.12", + "pretty-bytes": "^6.0.0", + "workbox-build": "^6.5.4", + "workbox-window": "^6.5.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^3.1.0 || ^4.0.0", + "workbox-build": "^6.5.4", + "workbox-window": "^6.5.4" + } }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "engines": { "node": ">=0.10.0" } }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "dependencies": { - "browser-process-hrtime": "^1.0.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", - "dependencies": { - "xml-name-validator": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/wait-on": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", - "integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", - "dev": true, - "dependencies": { - "axios": "^0.25.0", - "joi": "^17.6.0", - "lodash": "^4.17.21", - "minimist": "^1.2.5", - "rxjs": "^7.5.4" - }, - "bin": { - "wait-on": "bin/wait-on" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/wait-on/node_modules/rxjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", - "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/watchpack": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", - "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", - "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==", - "dependencies": { - "minimalistic-assert": "^1.0.0" + "node_modules/web-namespaces": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", + "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", - "engines": { - "node": ">=10.4" - } - }, - "node_modules/webpack": { - "version": "5.72.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.0.tgz", - "integrity": "sha512-qmSmbspI0Qo5ld49htys8GY9XhS9CGqFoHTsOVAnjBdg0Zn79y135R+k4IR4rKK6+eKaabMhJwiVB7xw0SJu5w==", - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.4.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.9.2", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-better-errors": "^1.0.2", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.3.1", - "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-dev-middleware": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.1.tgz", - "integrity": "sha512-81EujCKkyles2wphtdrnPg/QqegC/AtqNH//mQkBYSMqwFVCQrxM6ktB2O/SPlZy7LqeEfTbV3cZARGQz6umhg==", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.1", - "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-middleware/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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/webpack-dev-middleware/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==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-middleware/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==" - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack-dev-server": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.8.1.tgz", - "integrity": "sha512-dwld70gkgNJa33czmcj/PlKY/nOy/BimbrgZRaR9vDATBQAYgLzggR0nxDtPLJiLrMgZwbE6RRfJ5vnBBasTyg==", - "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/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": "^1.6.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", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "portfinder": "^1.0.28", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.0.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.21", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.4.2" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 12.13.0" - }, - "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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/webpack-dev-server/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==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-server/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==" - }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "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-manifest-plugin": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", - "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", - "dependencies": { - "tapable": "^2.0.0", - "webpack-sources": "^2.2.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "peerDependencies": { - "webpack": "^4.44.2 || ^5.47.0" - } - }, - "node_modules/webpack-manifest-plugin/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", - "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", - "dependencies": { - "source-list-map": "^2.0.1", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10.13.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==", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/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==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "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==", - "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==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "dependencies": { - "iconv-lite": "0.4.24" - } - }, - "node_modules/whatwg-encoding/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==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" - }, - "node_modules/whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, "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" }, @@ -18608,6 +7745,7 @@ "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", @@ -18619,47 +7757,90 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/widest-line": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", - "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", "dev": true, "dependencies": { - "string-width": "^4.0.0" + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "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-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/workbox-background-sync": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.3.tgz", - "integrity": "sha512-0DD/V05FAcek6tWv9XYj2w5T/plxhDSpclIcAGjA/b7t/6PdaRkQ7ZgtAX6Q/L7kV7wZ8uYRJUoH11VjNipMZw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", + "integrity": "sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==", + "dev": true, "dependencies": { - "idb": "^6.1.4", - "workbox-core": "6.5.3" + "idb": "^7.0.1", + "workbox-core": "6.6.0" } }, "node_modules/workbox-broadcast-update": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.3.tgz", - "integrity": "sha512-4AwCIA5DiDrYhlN+Miv/fp5T3/whNmSL+KqhTwRBTZIL6pvTgE4lVuRzAt1JltmqyMcQ3SEfCdfxczuI4kwFQg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz", + "integrity": "sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-build": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.3.tgz", - "integrity": "sha512-8JNHHS7u13nhwIYCDea9MNXBNPHXCs5KDZPKI/ZNTr3f4sMGoD7hgFGecbyjX1gw4z6e9bMpMsOEJNyH5htA/w==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.6.0.tgz", + "integrity": "sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==", + "dev": true, "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", "@babel/core": "^7.11.1", @@ -18683,30 +7864,31 @@ "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "6.5.3", - "workbox-broadcast-update": "6.5.3", - "workbox-cacheable-response": "6.5.3", - "workbox-core": "6.5.3", - "workbox-expiration": "6.5.3", - "workbox-google-analytics": "6.5.3", - "workbox-navigation-preload": "6.5.3", - "workbox-precaching": "6.5.3", - "workbox-range-requests": "6.5.3", - "workbox-recipes": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3", - "workbox-streams": "6.5.3", - "workbox-sw": "6.5.3", - "workbox-window": "6.5.3" + "workbox-background-sync": "6.6.0", + "workbox-broadcast-update": "6.6.0", + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-google-analytics": "6.6.0", + "workbox-navigation-preload": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-range-requests": "6.6.0", + "workbox-recipes": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0", + "workbox-streams": "6.6.0", + "workbox-sw": "6.6.0", + "workbox-window": "6.6.0" }, "engines": { "node": ">=10.0.0" } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.3.tgz", - "integrity": "sha512-9o+HO2MbJhJHjDYZaDxJmSDckvDpiuItEsrIShV0DXeCshXWRHhqYyU/PKHMkuClOmFnZhRd6wzv4vpDu/dRKg==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", @@ -18719,10 +7901,84 @@ "ajv": ">=8" } }, + "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, "node_modules/workbox-build/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "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", @@ -18734,29 +7990,60 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/workbox-build/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/workbox-build/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==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/workbox-build/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/workbox-build/node_modules/rollup": { + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/workbox-build/node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dev": true, "dependencies": { "whatwg-url": "^7.0.0" }, @@ -18767,7 +8054,8 @@ "node_modules/workbox-build/node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -18775,12 +8063,14 @@ "node_modules/workbox-build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true }, "node_modules/workbox-build/node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -18788,261 +8078,135 @@ } }, "node_modules/workbox-cacheable-response": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.3.tgz", - "integrity": "sha512-6JE/Zm05hNasHzzAGKDkqqgYtZZL2H06ic2GxuRLStA4S/rHUfm2mnLFFXuHAaGR1XuuYyVCEey1M6H3PdZ7SQ==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.6.0.tgz", + "integrity": "sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==", + "deprecated": "workbox-background-sync@6.6.0", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-core": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.3.tgz", - "integrity": "sha512-Bb9ey5n/M9x+l3fBTlLpHt9ASTzgSGj6vxni7pY72ilB/Pb3XtN+cZ9yueboVhD5+9cNQrC9n/E1fSrqWsUz7Q==" + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.6.0.tgz", + "integrity": "sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==", + "dev": true }, "node_modules/workbox-expiration": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.3.tgz", - "integrity": "sha512-jzYopYR1zD04ZMdlbn/R2Ik6ixiXbi15c9iX5H8CTi6RPDz7uhvMLZPKEndZTpfgmUk8mdmT9Vx/AhbuCl5Sqw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.6.0.tgz", + "integrity": "sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==", + "dev": true, "dependencies": { - "idb": "^6.1.4", - "workbox-core": "6.5.3" + "idb": "^7.0.1", + "workbox-core": "6.6.0" } }, "node_modules/workbox-google-analytics": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.3.tgz", - "integrity": "sha512-3GLCHotz5umoRSb4aNQeTbILETcrTVEozSfLhHSBaegHs1PnqCmN0zbIy2TjTpph2AGXiNwDrWGF0AN+UgDNTw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz", + "integrity": "sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==", + "dev": true, "dependencies": { - "workbox-background-sync": "6.5.3", - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" + "workbox-background-sync": "6.6.0", + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" } }, "node_modules/workbox-navigation-preload": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.3.tgz", - "integrity": "sha512-bK1gDFTc5iu6lH3UQ07QVo+0ovErhRNGvJJO/1ngknT0UQ702nmOUhoN9qE5mhuQSrnK+cqu7O7xeaJ+Rd9Tmg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.6.0.tgz", + "integrity": "sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-precaching": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.3.tgz", - "integrity": "sha512-sjNfgNLSsRX5zcc63H/ar/hCf+T19fRtTqvWh795gdpghWb5xsfEkecXEvZ8biEi1QD7X/ljtHphdaPvXDygMQ==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.6.0.tgz", + "integrity": "sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" } }, "node_modules/workbox-range-requests": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.3.tgz", - "integrity": "sha512-pGCP80Bpn/0Q0MQsfETSfmtXsQcu3M2QCJwSFuJ6cDp8s2XmbUXkzbuQhCUzKR86ZH2Vex/VUjb2UaZBGamijA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.6.0.tgz", + "integrity": "sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-recipes": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.3.tgz", - "integrity": "sha512-IcgiKYmbGiDvvf3PMSEtmwqxwfQ5zwI7OZPio3GWu4PfehA8jI8JHI3KZj+PCfRiUPZhjQHJ3v1HbNs+SiSkig==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.6.0.tgz", + "integrity": "sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==", + "dev": true, "dependencies": { - "workbox-cacheable-response": "6.5.3", - "workbox-core": "6.5.3", - "workbox-expiration": "6.5.3", - "workbox-precaching": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" } }, "node_modules/workbox-routing": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.3.tgz", - "integrity": "sha512-DFjxcuRAJjjt4T34RbMm3MCn+xnd36UT/2RfPRfa8VWJGItGJIn7tG+GwVTdHmvE54i/QmVTJepyAGWtoLPTmg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.6.0.tgz", + "integrity": "sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-strategies": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.3.tgz", - "integrity": "sha512-MgmGRrDVXs7rtSCcetZgkSZyMpRGw8HqL2aguszOc3nUmzGZsT238z/NN9ZouCxSzDu3PQ3ZSKmovAacaIhu1w==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.6.0.tgz", + "integrity": "sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3" + "workbox-core": "6.6.0" } }, "node_modules/workbox-streams": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.3.tgz", - "integrity": "sha512-vN4Qi8o+b7zj1FDVNZ+PlmAcy1sBoV7SC956uhqYvZ9Sg1fViSbOpydULOssVJ4tOyKRifH/eoi6h99d+sJ33w==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.6.0.tgz", + "integrity": "sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==", + "dev": true, "dependencies": { - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3" + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0" } }, "node_modules/workbox-sw": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.3.tgz", - "integrity": "sha512-BQBzm092w+NqdIEF2yhl32dERt9j9MDGUTa2Eaa+o3YKL4Qqw55W9yQC6f44FdAHdAJrJvp0t+HVrfh8AiGj8A==" - }, - "node_modules/workbox-webpack-plugin": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.3.tgz", - "integrity": "sha512-Es8Xr02Gi6Kc3zaUwR691ZLy61hz3vhhs5GztcklQ7kl5k2qAusPh0s6LF3wEtlpfs9ZDErnmy5SErwoll7jBA==", - "dependencies": { - "fast-json-stable-stringify": "^2.1.0", - "pretty-bytes": "^5.4.1", - "upath": "^1.2.0", - "webpack-sources": "^1.4.3", - "workbox-build": "6.5.3" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "webpack": "^4.4.0 || ^5.9.0" - } - }, - "node_modules/workbox-webpack-plugin/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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", - "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", - "dependencies": { - "source-list-map": "^2.0.0", - "source-map": "~0.6.1" - } + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.6.0.tgz", + "integrity": "sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==", + "dev": true }, "node_modules/workbox-window": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.3.tgz", - "integrity": "sha512-GnJbx1kcKXDtoJBVZs/P7ddP0Yt52NNy4nocjBpYPiRhMqTpJCNrSL+fGHZ/i/oP6p/vhE8II0sA6AZGKGnssw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.6.0.tgz", + "integrity": "sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==", + "dev": true, "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "6.5.3" + "workbox-core": "6.6.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/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/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "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/xdg-basedir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" - }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/xtend": { "version": "4.0.2", @@ -19052,18 +8216,11 @@ "node": ">=0.4" } }, - "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==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yaml": { "version": "1.10.2", @@ -19073,45 +8230,11 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "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/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==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", - "dev": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "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" }, @@ -19119,13810 +8242,5 @@ "url": "https://github.com/sponsors/sindresorhus" } } - }, - "dependencies": { - "@ampproject/remapping": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz", - "integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==", - "requires": { - "@jridgewell/trace-mapping": "^0.3.0" - } - }, - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/compat-data": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.7.tgz", - "integrity": "sha512-p8pdE6j0a29TNGebNm7NzYZWB3xVZJBZ7XGs42uAKzQo8VQ3F0By/cQCtUEABwIqw5zo6WA4NbmxsfzADzMKnQ==" - }, - "@babel/core": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", - "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", - "requires": { - "@ampproject/remapping": "^2.1.0", - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.9", - "@babel/helper-compilation-targets": "^7.17.7", - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helpers": "^7.17.9", - "@babel/parser": "^7.17.9", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.9", - "@babel/types": "^7.17.0", - "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/eslint-parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.17.0.tgz", - "integrity": "sha512-PUEJ7ZBXbRkbq3qqM/jZ2nIuakUBqCYc7Qf52Lj7dlZ6zERnqisdHioL0l4wwQZnmskMeasqUNzLBFKs3nylXA==", - "requires": { - "eslint-scope": "^5.1.1", - "eslint-visitor-keys": "^2.1.0", - "semver": "^6.3.0" - }, - "dependencies": { - "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==", - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==" - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" - } - } - }, - "@babel/generator": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.9.tgz", - "integrity": "sha512-rAdDousTwxbIxbz5I7GEQ3lUip+xVCXooZNbsydCWs3xA7ZsYOv+CFRdzGxRX78BmQHu9B1Eso59AOZQOJDEdQ==", - "requires": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - } - }, - "@babel/helper-annotate-as-pure": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", - "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.7.tgz", - "integrity": "sha512-C6FdbRaxYjwVu/geKW4ZeQ0Q31AftgRcdSnZ5/jsH6BzCJbtvXvhpfkbkThYSuutZA7nCXpPR6AD9zd1dprMkA==", - "requires": { - "@babel/helper-explode-assignable-expression": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-compilation-targets": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.7.tgz", - "integrity": "sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w==", - "requires": { - "@babel/compat-data": "^7.17.7", - "@babel/helper-validator-option": "^7.16.7", - "browserslist": "^4.17.5", - "semver": "^6.3.0" - } - }, - "@babel/helper-create-class-features-plugin": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz", - "integrity": "sha512-kUjip3gruz6AJKOq5i3nC6CoCEEF/oHH3cp6tOZhB+IyyyPyW0g1Gfsxn3mkk6S08pIA2y8GQh609v9G/5sHVQ==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.17.9", - "@babel/helper-member-expression-to-functions": "^7.17.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7" - } - }, - "@babel/helper-create-regexp-features-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.17.0.tgz", - "integrity": "sha512-awO2So99wG6KnlE+TPs6rn83gCz5WlEePJDTnLEqbchMVrBeAujURVphRdigsk094VhvZehFoNOihSlcBjwsXA==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "regexpu-core": "^5.0.1" - } - }, - "@babel/helper-define-polyfill-provider": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz", - "integrity": "sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==", - "requires": { - "@babel/helper-compilation-targets": "^7.13.0", - "@babel/helper-module-imports": "^7.12.13", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/traverse": "^7.13.0", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2", - "semver": "^6.1.2" - } - }, - "@babel/helper-environment-visitor": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", - "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-explode-assignable-expression": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", - "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-function-name": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", - "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", - "requires": { - "@babel/template": "^7.16.7", - "@babel/types": "^7.17.0" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", - "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-member-expression-to-functions": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz", - "integrity": "sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw==", - "requires": { - "@babel/types": "^7.17.0" - } - }, - "@babel/helper-module-imports": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-module-transforms": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", - "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", - "requires": { - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-simple-access": "^7.17.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/helper-validator-identifier": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.3", - "@babel/types": "^7.17.0" - } - }, - "@babel/helper-optimise-call-expression": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", - "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", - "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==" - }, - "@babel/helper-remap-async-to-generator": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", - "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-wrap-function": "^7.16.8", - "@babel/types": "^7.16.8" - } - }, - "@babel/helper-replace-supers": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", - "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", - "requires": { - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-member-expression-to-functions": "^7.16.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/traverse": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-simple-access": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", - "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", - "requires": { - "@babel/types": "^7.17.0" - } - }, - "@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", - "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", - "requires": { - "@babel/types": "^7.16.0" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", - "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" - }, - "@babel/helper-validator-option": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", - "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==" - }, - "@babel/helper-wrap-function": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", - "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", - "requires": { - "@babel/helper-function-name": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.16.8", - "@babel/types": "^7.16.8" - } - }, - "@babel/helpers": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", - "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", - "requires": { - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.9", - "@babel/types": "^7.17.0" - } - }, - "@babel/highlight": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", - "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "@babel/parser": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.9.tgz", - "integrity": "sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg==" - }, - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.16.7", - "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.16.7.tgz", - "integrity": "sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.16.7.tgz", - "integrity": "sha512-di8vUHRdf+4aJ7ltXhaDbPoszdkh59AQtJM5soLsuHpQJdFQZOA4uGj0V2u/CZ8bJ/u8ULDL5yq6FO/bCXnKHw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", - "@babel/plugin-proposal-optional-chaining": "^7.16.7" - } - }, - "@babel/plugin-proposal-async-generator-functions": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.16.8.tgz", - "integrity": "sha512-71YHIvMuiuqWJQkebWJtdhQTfd4Q4mF76q2IX37uZPkG9+olBxsX+rH1vkhFto4UeJZ9dPY2s+mDvhDm1u2BGQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-remap-async-to-generator": "^7.16.8", - "@babel/plugin-syntax-async-generators": "^7.8.4" - } - }, - "@babel/plugin-proposal-class-properties": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz", - "integrity": "sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww==", - "requires": { - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-proposal-class-static-block": { - "version": "7.17.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz", - "integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==", - "requires": { - "@babel/helper-create-class-features-plugin": "^7.17.6", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-class-static-block": "^7.14.5" - } - }, - "@babel/plugin-proposal-decorators": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.17.9.tgz", - "integrity": "sha512-EfH2LZ/vPa2wuPwJ26j+kYRkaubf89UlwxKXtxqEm57HrgSEYDB8t4swFP+p8LcI9yiP9ZRJJjo/58hS6BnaDA==", - "requires": { - "@babel/helper-create-class-features-plugin": "^7.17.9", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/plugin-syntax-decorators": "^7.17.0", - "charcodes": "^0.2.0" - } - }, - "@babel/plugin-proposal-dynamic-import": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz", - "integrity": "sha512-I8SW9Ho3/8DRSdmDdH3gORdyUuYnk1m4cMxUAdu5oy4n3OfN8flDEH+d60iG7dUfi0KkYwSvoalHzzdRzpWHTg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" - } - }, - "@babel/plugin-proposal-export-namespace-from": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.16.7.tgz", - "integrity": "sha512-ZxdtqDXLRGBL64ocZcs7ovt71L3jhC1RGSyR996svrCi3PYqHNkb3SwPJCs8RIzD86s+WPpt2S73+EHCGO+NUA==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" - } - }, - "@babel/plugin-proposal-json-strings": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.16.7.tgz", - "integrity": "sha512-lNZ3EEggsGY78JavgbHsK9u5P3pQaW7k4axlgFLYkMd7UBsiNahCITShLjNQschPyjtO6dADrL24757IdhBrsQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-json-strings": "^7.8.3" - } - }, - "@babel/plugin-proposal-logical-assignment-operators": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.16.7.tgz", - "integrity": "sha512-K3XzyZJGQCr00+EtYtrDjmwX7o7PLK6U9bi1nCwkQioRFVUv6dJoxbQjtWVtP+bCPy82bONBKG8NPyQ4+i6yjg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" - } - }, - "@babel/plugin-proposal-nullish-coalescing-operator": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.16.7.tgz", - "integrity": "sha512-aUOrYU3EVtjf62jQrCj63pYZ7k6vns2h/DQvHPWGmsJRYzWXZ6/AsfgpiRy6XiuIDADhJzP2Q9MwSMKauBQ+UQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" - } - }, - "@babel/plugin-proposal-numeric-separator": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.16.7.tgz", - "integrity": "sha512-vQgPMknOIgiuVqbokToyXbkY/OmmjAzr/0lhSIbG/KmnzXPGwW/AdhdKpi+O4X/VkWiWjnkKOBiqJrTaC98VKw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" - } - }, - "@babel/plugin-proposal-object-rest-spread": { - "version": "7.17.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.17.3.tgz", - "integrity": "sha512-yuL5iQA/TbZn+RGAfxQXfi7CNLmKi1f8zInn4IgobuCWcAb7i+zj4TYzQ9l8cEzVyJ89PDGuqxK1xZpUDISesw==", - "requires": { - "@babel/compat-data": "^7.17.0", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.16.7" - } - }, - "@babel/plugin-proposal-optional-catch-binding": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", - "integrity": "sha512-eMOH/L4OvWSZAE1VkHbr1vckLG1WUcHGJSLqqQwl2GaUqG6QjddvrOaTUMNYiv77H5IKPMZ9U9P7EaHwvAShfA==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" - } - }, - "@babel/plugin-proposal-optional-chaining": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.16.7.tgz", - "integrity": "sha512-eC3xy+ZrUcBtP7x+sq62Q/HYd674pPTb/77XZMb5wbDPGWIdUbSr4Agr052+zaUPSb+gGRnjxXfKFvx5iMJ+DA==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - } - }, - "@babel/plugin-proposal-private-methods": { - "version": "7.16.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.16.11.tgz", - "integrity": "sha512-F/2uAkPlXDr8+BHpZvo19w3hLFKge+k75XUprE6jaqKxjGkSYcK+4c+bup5PdW/7W/Rpjwql7FTVEDW+fRAQsw==", - "requires": { - "@babel/helper-create-class-features-plugin": "^7.16.10", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-proposal-private-property-in-object": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.16.7.tgz", - "integrity": "sha512-rMQkjcOFbm+ufe3bTZLyOfsOUOxyvLXZJCTARhJr+8UMSoZmqTe1K1BgkFcrW37rAchWg57yI69ORxiWvUINuQ==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" - } - }, - "@babel/plugin-proposal-unicode-property-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.16.7.tgz", - "integrity": "sha512-QRK0YI/40VLhNVGIjRNAAQkEHws0cswSdFFjpFyt943YmJIU1da9uW63Iu6NFV6CxTZW5eTDCrwZUstBWgp/Rg==", - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@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==", - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "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==", - "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==", - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-decorators": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.0.tgz", - "integrity": "sha512-qWe85yCXsvDEluNP0OyeQjH63DlhAR3W7K9BxxU1MvbDb48tgBG+Ao6IJJ6smPDrrVzSQZrbF6donpkFBMcs3A==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@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==", - "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==", - "requires": { - "@babel/helper-plugin-utils": "^7.8.3" - } - }, - "@babel/plugin-syntax-flow": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.16.7.tgz", - "integrity": "sha512-UDo3YGQO0jH6ytzVwgSLv9i/CzMcUjbKenL67dTrAZPPv6GFAtDhe6jqnvmoKzC/7htNTohhos+onPtDMqJwaQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@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==", - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-jsx": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", - "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@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==", - "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==", - "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==", - "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==", - "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==", - "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==", - "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==", - "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==", - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-typescript": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz", - "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-arrow-functions": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz", - "integrity": "sha512-9ffkFFMbvzTvv+7dTp/66xvZAWASuPD5Tl9LK3Z9vhOmANo6j94rik+5YMBt4CwHVMWLWpMsriIc2zsa3WW3xQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-async-to-generator": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.16.8.tgz", - "integrity": "sha512-MtmUmTJQHCnyJVrScNzNlofQJ3dLFuobYn3mwOTKHnSCMtbNsqvF71GQmJfFjdrXSsAA7iysFmYWw4bXZ20hOg==", - "requires": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-remap-async-to-generator": "^7.16.8" - } - }, - "@babel/plugin-transform-block-scoped-functions": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.16.7.tgz", - "integrity": "sha512-JUuzlzmF40Z9cXyytcbZEZKckgrQzChbQJw/5PuEHYeqzCsvebDx0K0jWnIIVcmmDOAVctCgnYs0pMcrYj2zJg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-block-scoping": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.16.7.tgz", - "integrity": "sha512-ObZev2nxVAYA4bhyusELdo9hb3H+A56bxH3FZMbEImZFiEDYVHXQSJ1hQKFlDnlt8G9bBrCZ5ZpURZUrV4G5qQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-classes": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.16.7.tgz", - "integrity": "sha512-WY7og38SFAGYRe64BrjKf8OrE6ulEHtr5jEYaZMwox9KebgqPi67Zqz8K53EKk1fFEJgm96r32rkKZ3qA2nCWQ==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-optimise-call-expression": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "globals": "^11.1.0" - } - }, - "@babel/plugin-transform-computed-properties": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.16.7.tgz", - "integrity": "sha512-gN72G9bcmenVILj//sv1zLNaPyYcOzUho2lIJBMh/iakJ9ygCo/hEF9cpGb61SCMEDxbbyBoVQxrt+bWKu5KGw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-destructuring": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.17.7.tgz", - "integrity": "sha512-XVh0r5yq9sLR4vZ6eVZe8FKfIcSgaTBxVBRSYokRj2qksf6QerYnTxz9/GTuKTH/n/HwLP7t6gtlybHetJ/6hQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-dotall-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.16.7.tgz", - "integrity": "sha512-Lyttaao2SjZF6Pf4vk1dVKv8YypMpomAbygW+mU5cYP3S5cWTfCJjG8xV6CFdzGFlfWK81IjL9viiTvpb6G7gQ==", - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-duplicate-keys": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.16.7.tgz", - "integrity": "sha512-03DvpbRfvWIXyK0/6QiR1KMTWeT6OcQ7tbhjrXyFS02kjuX/mu5Bvnh5SDSWHxyawit2g5aWhKwI86EE7GUnTw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-exponentiation-operator": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.16.7.tgz", - "integrity": "sha512-8UYLSlyLgRixQvlYH3J2ekXFHDFLQutdy7FfFAMm3CPZ6q9wHCwnUyiXpQCe3gVVnQlHc5nsuiEVziteRNTXEA==", - "requires": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-flow-strip-types": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.16.7.tgz", - "integrity": "sha512-mzmCq3cNsDpZZu9FADYYyfZJIOrSONmHcop2XEKPdBNMa4PDC4eEvcOvzZaCNcjKu72v0XQlA5y1g58aLRXdYg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-flow": "^7.16.7" - } - }, - "@babel/plugin-transform-for-of": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.7.tgz", - "integrity": "sha512-/QZm9W92Ptpw7sjI9Nx1mbcsWz33+l8kuMIQnDwgQBG5s3fAfQvkRjQ7NqXhtNcKOnPkdICmUHyCaWW06HCsqg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-function-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.16.7.tgz", - "integrity": "sha512-SU/C68YVwTRxqWj5kgsbKINakGag0KTgq9f2iZEXdStoAbOzLHEBRYzImmA6yFo8YZhJVflvXmIHUO7GWHmxxA==", - "requires": { - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-function-name": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.16.7.tgz", - "integrity": "sha512-6tH8RTpTWI0s2sV6uq3e/C9wPo4PTqqZps4uF0kzQ9/xPLFQtipynvmT1g/dOfEJ+0EQsHhkQ/zyRId8J2b8zQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-member-expression-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.16.7.tgz", - "integrity": "sha512-mBruRMbktKQwbxaJof32LT9KLy2f3gH+27a5XSuXo6h7R3vqltl0PgZ80C8ZMKw98Bf8bqt6BEVi3svOh2PzMw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-modules-amd": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz", - "integrity": "sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g==", - "requires": { - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "babel-plugin-dynamic-import-node": "^2.3.3" - } - }, - "@babel/plugin-transform-modules-commonjs": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.17.9.tgz", - "integrity": "sha512-2TBFd/r2I6VlYn0YRTz2JdazS+FoUuQ2rIFHoAxtyP/0G3D82SBLaRq9rnUkpqlLg03Byfl/+M32mpxjO6KaPw==", - "requires": { - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-simple-access": "^7.17.7", - "babel-plugin-dynamic-import-node": "^2.3.3" - } - }, - "@babel/plugin-transform-modules-systemjs": { - "version": "7.17.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.17.8.tgz", - "integrity": "sha512-39reIkMTUVagzgA5x88zDYXPCMT6lcaRKs1+S9K6NKBPErbgO/w/kP8GlNQTC87b412ZTlmNgr3k2JrWgHH+Bw==", - "requires": { - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-module-transforms": "^7.17.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-identifier": "^7.16.7", - "babel-plugin-dynamic-import-node": "^2.3.3" - } - }, - "@babel/plugin-transform-modules-umd": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.16.7.tgz", - "integrity": "sha512-EMh7uolsC8O4xhudF2F6wedbSHm1HHZ0C6aJ7K67zcDNidMzVcxWdGr+htW9n21klm+bOn+Rx4CBsAntZd3rEQ==", - "requires": { - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.16.8.tgz", - "integrity": "sha512-j3Jw+n5PvpmhRR+mrgIh04puSANCk/T/UA3m3P1MjJkhlK906+ApHhDIqBQDdOgL/r1UYpz4GNclTXxyZrYGSw==", - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7" - } - }, - "@babel/plugin-transform-new-target": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.16.7.tgz", - "integrity": "sha512-xiLDzWNMfKoGOpc6t3U+etCE2yRnn3SM09BXqWPIZOBpL2gvVrBWUKnsJx0K/ADi5F5YC5f8APFfWrz25TdlGg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-object-super": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.16.7.tgz", - "integrity": "sha512-14J1feiQVWaGvRxj2WjyMuXS2jsBkgB3MdSN5HuC2G5nRspa5RK9COcs82Pwy5BuGcjb+fYaUj94mYcOj7rCvw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-replace-supers": "^7.16.7" - } - }, - "@babel/plugin-transform-parameters": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.16.7.tgz", - "integrity": "sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-property-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.16.7.tgz", - "integrity": "sha512-z4FGr9NMGdoIl1RqavCqGG+ZuYjfZ/hkCIeuH6Do7tXmSm0ls11nYVSJqFEUOSJbDab5wC6lRE/w6YjVcr6Hqw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-react-constant-elements": { - "version": "7.17.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.17.6.tgz", - "integrity": "sha512-OBv9VkyyKtsHZiHLoSfCn+h6yU7YKX8nrs32xUmOa1SRSk+t03FosB6fBZ0Yz4BpD1WV7l73Nsad+2Tz7APpqw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-react-display-name": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz", - "integrity": "sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-react-jsx": { - "version": "7.17.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", - "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-jsx": "^7.16.7", - "@babel/types": "^7.17.0" - } - }, - "@babel/plugin-transform-react-jsx-development": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", - "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", - "requires": { - "@babel/plugin-transform-react-jsx": "^7.16.7" - } - }, - "@babel/plugin-transform-react-pure-annotations": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz", - "integrity": "sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-regenerator": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.17.9.tgz", - "integrity": "sha512-Lc2TfbxR1HOyn/c6b4Y/b6NHoTb67n/IoWLxTu4kC7h4KQnWlhCq2S8Tx0t2SVvv5Uu87Hs+6JEJ5kt2tYGylQ==", - "requires": { - "regenerator-transform": "^0.15.0" - } - }, - "@babel/plugin-transform-reserved-words": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.16.7.tgz", - "integrity": "sha512-KQzzDnZ9hWQBjwi5lpY5v9shmm6IVG0U9pB18zvMu2i4H90xpT4gmqwPYsn8rObiadYe2M0gmgsiOIF5A/2rtg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-runtime": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.0.tgz", - "integrity": "sha512-fr7zPWnKXNc1xoHfrIU9mN/4XKX4VLZ45Q+oMhfsYIaHvg7mHgmhfOy/ckRWqDK7XF3QDigRpkh5DKq6+clE8A==", - "requires": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "babel-plugin-polyfill-corejs2": "^0.3.0", - "babel-plugin-polyfill-corejs3": "^0.5.0", - "babel-plugin-polyfill-regenerator": "^0.3.0", - "semver": "^6.3.0" - } - }, - "@babel/plugin-transform-shorthand-properties": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.16.7.tgz", - "integrity": "sha512-hah2+FEnoRoATdIb05IOXf+4GzXYTq75TVhIn1PewihbpyrNWUt2JbudKQOETWw6QpLe+AIUpJ5MVLYTQbeeUg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-spread": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.16.7.tgz", - "integrity": "sha512-+pjJpgAngb53L0iaA5gU/1MLXJIfXcYepLgXB3esVRf4fqmj8f2cxM3/FKaHsZms08hFQJkFccEWuIpm429TXg==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0" - } - }, - "@babel/plugin-transform-sticky-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.16.7.tgz", - "integrity": "sha512-NJa0Bd/87QV5NZZzTuZG5BPJjLYadeSZ9fO6oOUoL4iQx+9EEuw/eEM92SrsT19Yc2jgB1u1hsjqDtH02c3Drw==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-template-literals": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.16.7.tgz", - "integrity": "sha512-VwbkDDUeenlIjmfNeDX/V0aWrQH2QiVyJtwymVQSzItFDTpxfyJh3EVaQiS0rIN/CqbLGr0VcGmuwyTdZtdIsA==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-typeof-symbol": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.16.7.tgz", - "integrity": "sha512-p2rOixCKRJzpg9JB4gjnG4gjWkWa89ZoYUnl9snJ1cWIcTH/hvxZqfO+WjG6T8DRBpctEol5jw1O5rA8gkCokQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-typescript": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", - "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", - "requires": { - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-typescript": "^7.16.7" - } - }, - "@babel/plugin-transform-unicode-escapes": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", - "integrity": "sha512-TAV5IGahIz3yZ9/Hfv35TV2xEm+kaBDaZQCn2S/hG9/CZ0DktxJv9eKfPc7yYCvOYR4JGx1h8C+jcSOvgaaI/Q==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/plugin-transform-unicode-regex": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.16.7.tgz", - "integrity": "sha512-oC5tYYKw56HO75KZVLQ+R/Nl3Hro9kf8iG0hXoaHP7tjAyCpvqBiSNe6vGrZni1Z6MggmUOC6A7VP7AVmw225Q==", - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7" - } - }, - "@babel/preset-env": { - "version": "7.16.11", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.16.11.tgz", - "integrity": "sha512-qcmWG8R7ZW6WBRPZK//y+E3Cli151B20W1Rv7ln27vuPaXU/8TKms6jFdiJtF7UDTxcrb7mZd88tAeK9LjdT8g==", - "requires": { - "@babel/compat-data": "^7.16.8", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.16.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.16.7", - "@babel/plugin-proposal-async-generator-functions": "^7.16.8", - "@babel/plugin-proposal-class-properties": "^7.16.7", - "@babel/plugin-proposal-class-static-block": "^7.16.7", - "@babel/plugin-proposal-dynamic-import": "^7.16.7", - "@babel/plugin-proposal-export-namespace-from": "^7.16.7", - "@babel/plugin-proposal-json-strings": "^7.16.7", - "@babel/plugin-proposal-logical-assignment-operators": "^7.16.7", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", - "@babel/plugin-proposal-numeric-separator": "^7.16.7", - "@babel/plugin-proposal-object-rest-spread": "^7.16.7", - "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", - "@babel/plugin-proposal-optional-chaining": "^7.16.7", - "@babel/plugin-proposal-private-methods": "^7.16.11", - "@babel/plugin-proposal-private-property-in-object": "^7.16.7", - "@babel/plugin-proposal-unicode-property-regex": "^7.16.7", - "@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-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.16.7", - "@babel/plugin-transform-async-to-generator": "^7.16.8", - "@babel/plugin-transform-block-scoped-functions": "^7.16.7", - "@babel/plugin-transform-block-scoping": "^7.16.7", - "@babel/plugin-transform-classes": "^7.16.7", - "@babel/plugin-transform-computed-properties": "^7.16.7", - "@babel/plugin-transform-destructuring": "^7.16.7", - "@babel/plugin-transform-dotall-regex": "^7.16.7", - "@babel/plugin-transform-duplicate-keys": "^7.16.7", - "@babel/plugin-transform-exponentiation-operator": "^7.16.7", - "@babel/plugin-transform-for-of": "^7.16.7", - "@babel/plugin-transform-function-name": "^7.16.7", - "@babel/plugin-transform-literals": "^7.16.7", - "@babel/plugin-transform-member-expression-literals": "^7.16.7", - "@babel/plugin-transform-modules-amd": "^7.16.7", - "@babel/plugin-transform-modules-commonjs": "^7.16.8", - "@babel/plugin-transform-modules-systemjs": "^7.16.7", - "@babel/plugin-transform-modules-umd": "^7.16.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.16.8", - "@babel/plugin-transform-new-target": "^7.16.7", - "@babel/plugin-transform-object-super": "^7.16.7", - "@babel/plugin-transform-parameters": "^7.16.7", - "@babel/plugin-transform-property-literals": "^7.16.7", - "@babel/plugin-transform-regenerator": "^7.16.7", - "@babel/plugin-transform-reserved-words": "^7.16.7", - "@babel/plugin-transform-shorthand-properties": "^7.16.7", - "@babel/plugin-transform-spread": "^7.16.7", - "@babel/plugin-transform-sticky-regex": "^7.16.7", - "@babel/plugin-transform-template-literals": "^7.16.7", - "@babel/plugin-transform-typeof-symbol": "^7.16.7", - "@babel/plugin-transform-unicode-escapes": "^7.16.7", - "@babel/plugin-transform-unicode-regex": "^7.16.7", - "@babel/preset-modules": "^0.1.5", - "@babel/types": "^7.16.8", - "babel-plugin-polyfill-corejs2": "^0.3.0", - "babel-plugin-polyfill-corejs3": "^0.5.0", - "babel-plugin-polyfill-regenerator": "^0.3.0", - "core-js-compat": "^3.20.2", - "semver": "^6.3.0" - } - }, - "@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==", - "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/preset-react": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.7.tgz", - "integrity": "sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-transform-react-display-name": "^7.16.7", - "@babel/plugin-transform-react-jsx": "^7.16.7", - "@babel/plugin-transform-react-jsx-development": "^7.16.7", - "@babel/plugin-transform-react-pure-annotations": "^7.16.7" - } - }, - "@babel/preset-typescript": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", - "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-transform-typescript": "^7.16.7" - } - }, - "@babel/runtime": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@babel/runtime-corejs3": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz", - "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==", - "requires": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" - } - }, - "@babel/template": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", - "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", - "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/parser": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/traverse": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.9.tgz", - "integrity": "sha512-PQO8sDIJ8SIwipTPiR71kJQCKQYB5NGImbOviK8K+kg5xkNSYXLBupuX9QhatFowrsvo9Hj8WgArg3W7ijNAQw==", - "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.9", - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-function-name": "^7.17.9", - "@babel/helper-hoist-variables": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/parser": "^7.17.9", - "@babel/types": "^7.17.0", - "debug": "^4.1.0", - "globals": "^11.1.0" - } - }, - "@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "to-fast-properties": "^2.0.0" - } - }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" - }, - "@csstools/normalize.css": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", - "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==" - }, - "@csstools/postcss-color-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.0.tgz", - "integrity": "sha512-5D5ND/mZWcQoSfYnSPsXtuiFxhzmhxt6pcjrFLJyldj+p0ZN2vvRpYNX+lahFTtMhAYOa2WmkdGINr0yP0CvGA==", - "requires": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-font-format-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.0.tgz", - "integrity": "sha512-oO0cZt8do8FdVBX8INftvIA4lUrKUSCcWUf9IwH9IPWOgKT22oAZFXeHLoDK7nhB2SmkNycp5brxfNMRLIhd6Q==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-hwb-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.0.tgz", - "integrity": "sha512-VSTd7hGjmde4rTj1rR30sokY3ONJph1reCBTUXqeW1fKwETPy1x4t/XIeaaqbMbC5Xg4SM/lyXZ2S8NELT2TaA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-ic-unit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.0.tgz", - "integrity": "sha512-i4yps1mBp2ijrx7E96RXrQXQQHm6F4ym1TOD0D69/sjDjZvQ22tqiEvaNw7pFZTUO5b9vWRHzbHzP9+UKuw+bA==", - "requires": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-is-pseudo-class": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.2.tgz", - "integrity": "sha512-L9h1yxXMj7KpgNzlMrw3isvHJYkikZgZE4ASwssTnGEH8tm50L6QsM9QQT5wR4/eO5mU0rN5axH7UzNxEYg5CA==", - "requires": { - "postcss-selector-parser": "^6.0.10" - } - }, - "@csstools/postcss-normalize-display-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.0.tgz", - "integrity": "sha512-bX+nx5V8XTJEmGtpWTO6kywdS725t71YSLlxWt78XoHUbELWgoCXeOFymRJmL3SU1TLlKSIi7v52EWqe60vJTQ==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-oklab-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.0.tgz", - "integrity": "sha512-e/Q5HopQzmnQgqimG9v3w2IG4VRABsBq3itOcn4bnm+j4enTgQZ0nWsaH/m9GV2otWGQ0nwccYL5vmLKyvP1ww==", - "requires": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "@csstools/postcss-progressive-custom-properties": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", - "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "requires": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - } - }, - "@electron/get": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.14.1.tgz", - "integrity": "sha512-BrZYyL/6m0ZXz/lDxy/nlVhQz+WF+iPS6qXolEU8atw7h6v1aYkjwJZ63m+bJMBTxDE66X+r2tPS4a/8C82sZw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "global-agent": "^3.0.0", - "global-tunnel-ng": "^2.7.1", - "got": "^9.6.0", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "dependencies": { - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true - } - } - }, - "@electron/universal": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.2.0.tgz", - "integrity": "sha512-eu20BwNsrMPKoe2bZ3/l9c78LclDvxg3PlVXrQf3L50NaUuW5M59gbPytI+V4z7/QMrohUHetQaU0ou+p1UG9Q==", - "dev": true, - "requires": { - "@malept/cross-spawn-promise": "^1.1.0", - "asar": "^3.1.0", - "debug": "^4.3.1", - "dir-compare": "^2.4.0", - "fs-extra": "^9.0.1", - "minimatch": "^3.0.4", - "plist": "^3.0.4" - }, - "dependencies": { - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - } - } - }, - "@emotion/babel-plugin": { - "version": "11.9.2", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", - "integrity": "sha512-Pr/7HGH6H6yKgnVFNEj2MVlreu3ADqftqjqwUvDy/OJzKFgxKeTQ+eeUf20FOTuHVkDON2iNa25rAXVYtWJCjw==", - "requires": { - "@babel/helper-module-imports": "^7.12.13", - "@babel/plugin-syntax-jsx": "^7.12.13", - "@babel/runtime": "^7.13.10", - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.5", - "@emotion/serialize": "^1.0.2", - "babel-plugin-macros": "^2.6.1", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.0.13" - } - }, - "@emotion/cache": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.7.1.tgz", - "integrity": "sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==", - "requires": { - "@emotion/memoize": "^0.7.4", - "@emotion/sheet": "^1.1.0", - "@emotion/utils": "^1.0.0", - "@emotion/weak-memoize": "^0.2.5", - "stylis": "4.0.13" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" - }, - "@emotion/is-prop-valid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.1.2.tgz", - "integrity": "sha512-3QnhqeL+WW88YjYbQL5gUIkthuMw7a0NGbZ7wfFVk2kg/CK5w8w5FFa0RzWjyY1+sujN0NWbtSHH6OJmWHtJpQ==", - "requires": { - "@emotion/memoize": "^0.7.4" - } - }, - "@emotion/memoize": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz", - "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==" - }, - "@emotion/react": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.0.tgz", - "integrity": "sha512-lBVSF5d0ceKtfKCDQJveNAtkC7ayxpVlgOohLgXqRwqWr9bOf4TZAFFyIcNngnV6xK6X4x2ZeXq7vliHkoVkxQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/cache": "^11.7.1", - "@emotion/serialize": "^1.0.3", - "@emotion/utils": "^1.1.0", - "@emotion/weak-memoize": "^0.2.5", - "hoist-non-react-statics": "^3.3.1" - } - }, - "@emotion/serialize": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-2mSSvgLfyV3q+iVh3YWgNlUc2a9ZlDU7DjuP5MjK3AXRR0dYigCrP99aeFtaB2L/hjfEZdSThn5dsZ0ufqbvsA==", - "requires": { - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.4", - "@emotion/unitless": "^0.7.5", - "@emotion/utils": "^1.0.0", - "csstype": "^3.0.2" - } - }, - "@emotion/sheet": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.0.tgz", - "integrity": "sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==" - }, - "@emotion/styled": { - "version": "11.8.1", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz", - "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/is-prop-valid": "^1.1.2", - "@emotion/serialize": "^1.0.2", - "@emotion/utils": "^1.1.0" - } - }, - "@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" - }, - "@emotion/utils": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz", - "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ==" - }, - "@emotion/weak-memoize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", - "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" - }, - "@eslint/eslintrc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.2.tgz", - "integrity": "sha512-lTVWHs7O2hjBFZunXTZYnYqtB9GakA1lnxIf+gKq2nY5gxkkNi/lQvveW6t8gFdOHTg6nG50Xs95PrLqVpcaLg==", - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.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==" - }, - "globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "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==", - "requires": { - "argparse": "^2.0.1" - } - }, - "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==" - } - } - }, - "@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true - }, - "@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - } - }, - "@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==" - }, - "@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==", - "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": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.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==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" - } - } - }, - "@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==" - }, - "@jest/console": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", - "requires": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jest/core": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", - "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", - "requires": { - "@jest/console": "^27.5.1", - "@jest/reporters": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^27.5.1", - "jest-config": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-resolve-dependencies": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "jest-watcher": "^27.5.1", - "micromatch": "^4.0.4", - "rimraf": "^3.0.0", - "slash": "^3.0.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jest/environment": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", - "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", - "requires": { - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1" - } - }, - "@jest/fake-timers": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", - "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", - "requires": { - "@jest/types": "^27.5.1", - "@sinonjs/fake-timers": "^8.0.1", - "@types/node": "*", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - } - }, - "@jest/globals": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", - "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/types": "^27.5.1", - "expect": "^27.5.1" - } - }, - "@jest/reporters": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", - "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", - "requires": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.2", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-haste-map": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "slash": "^3.0.0", - "source-map": "^0.6.0", - "string-length": "^4.0.1", - "terminal-link": "^2.0.0", - "v8-to-istanbul": "^8.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jest/source-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", - "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", - "requires": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9", - "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==" - } - } - }, - "@jest/test-result": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", - "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", - "requires": { - "@jest/console": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - } - }, - "@jest/test-sequencer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", - "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", - "requires": { - "@jest/test-result": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-runtime": "^27.5.1" - } - }, - "@jest/transform": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", - "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", - "requires": { - "@babel/core": "^7.1.0", - "@jest/types": "^27.5.1", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-util": "^27.5.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jest/types": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", - "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", - "requires": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^16.0.0", - "chalk": "^4.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jridgewell/resolve-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", - "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==" - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", - "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" - }, - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "@leichtgewicht/ip-codec": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz", - "integrity": "sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==" - }, - "@malept/cross-spawn-promise": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", - "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.1" - } - }, - "@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, - "dependencies": { - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - } - } - }, - "@mui/base": { - "version": "5.0.0-alpha.77", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.77.tgz", - "integrity": "sha512-Zqm3qlczGViD3lJSYo8ZnQLHJ3PwGYftbDfVuh2Rq5OD88F7H6oDILlqknzty59NDkeSVO2qlymYmHOY1nLodg==", - "requires": { - "@babel/runtime": "^7.17.2", - "@emotion/is-prop-valid": "^1.1.2", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "@popperjs/core": "^2.11.5", - "clsx": "^1.1.1", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - } - }, - "@mui/icons-material": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.6.2.tgz", - "integrity": "sha512-9QdI7axKuBAyaGz4mtdi7Uy1j73/thqFmEuxpJHxNC7O8ADEK1Da3t2veK2tgmsXsUlAHcAG63gg+GvWWeQNqQ==", - "requires": { - "@babel/runtime": "^7.17.2" - } - }, - "@mui/material": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.6.2.tgz", - "integrity": "sha512-bwMvroBrMgUTwUh/BcjhtcJwEw9uH4chV3+ZSj6RckOJtMj8U4yEeD7S4NgHE8Ioj5eObKFzHpih/cTD1sDRpg==", - "requires": { - "@babel/runtime": "^7.17.2", - "@mui/base": "5.0.0-alpha.77", - "@mui/system": "^5.6.2", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "@types/react-transition-group": "^4.4.4", - "clsx": "^1.1.1", - "csstype": "^3.0.11", - "hoist-non-react-statics": "^3.3.2", - "prop-types": "^15.7.2", - "react-is": "^17.0.2", - "react-transition-group": "^4.4.2" - } - }, - "@mui/private-theming": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.6.2.tgz", - "integrity": "sha512-IbrSfFXfiZdyhRMC2bgGTFtb16RBQ5mccmjeh3MtAERWuepiCK7gkW5D9WhEsfTu6iez+TEjeUKSgmMHlsM2mg==", - "requires": { - "@babel/runtime": "^7.17.2", - "@mui/utils": "^5.6.1", - "prop-types": "^15.7.2" - } - }, - "@mui/styled-engine": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.6.1.tgz", - "integrity": "sha512-jEhH6TBY8jc9S8yVncXmoTYTbATjEu44RMFXj6sIYfKr5NArVwTwRo3JexLL0t3BOAiYM4xsFLgfKEIvB9SAeQ==", - "requires": { - "@babel/runtime": "^7.17.2", - "@emotion/cache": "^11.7.1", - "prop-types": "^15.7.2" - } - }, - "@mui/system": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.6.2.tgz", - "integrity": "sha512-Wg9TRbvavSwEYk6UdpnoDx+CqJfaAN7AzlmwEx7DtGmx0snFVBST8FVb1Ev1vXosxEnq6/fe7ZDRobFVewvEPQ==", - "requires": { - "@babel/runtime": "^7.17.2", - "@mui/private-theming": "^5.6.2", - "@mui/styled-engine": "^5.6.1", - "@mui/types": "^7.1.3", - "@mui/utils": "^5.6.1", - "clsx": "^1.1.1", - "csstype": "^3.0.11", - "prop-types": "^15.7.2" - } - }, - "@mui/types": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.3.tgz", - "integrity": "sha512-DDF0UhMBo4Uezlk+6QxrlDbchF79XG6Zs0zIewlR4c0Dt6GKVFfUtzPtHCH1tTbcSlq/L2bGEdiaoHBJ9Y1gSA==", - "requires": {} - }, - "@mui/utils": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.6.1.tgz", - "integrity": "sha512-CPrzrkiBusCZBLWu0Sg5MJvR3fKJyK3gKecLVX012LULyqg2U64Oz04BKhfkbtBrPBbSQxM+DWW9B1c9hmV9nQ==", - "requires": { - "@babel/runtime": "^7.17.2", - "@types/prop-types": "^15.7.4", - "@types/react-is": "^16.7.1 || ^17.0.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - } - }, - "@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==", - "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==" - }, - "@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==", - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz", - "integrity": "sha512-RbG7h6TuP6nFFYKJwbcToA1rjC1FyPg25NR2noAZ0vKI+la01KTSRPkuVPE+U88jXv7javx2JHglUcL1MHcshQ==", - "requires": { - "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.8.1", - "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0", - "source-map": "^0.7.3" - }, - "dependencies": { - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" - } - } - }, - "@popperjs/core": { - "version": "2.11.5", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", - "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==" - }, - "@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "requires": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - } - }, - "@rollup/plugin-node-resolve": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", - "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", - "requires": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - } - }, - "@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "requires": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - } - }, - "@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "requires": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "dependencies": { - "@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" - } - } - }, - "@rushstack/eslint-patch": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz", - "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==" - }, - "@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@sideway/formula": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", - "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==", - "dev": true - }, - "@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, - "@sindresorhus/is": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", - "dev": true - }, - "@sinonjs/commons": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", - "requires": { - "type-detect": "4.0.8" - } - }, - "@sinonjs/fake-timers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", - "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", - "requires": { - "@sinonjs/commons": "^1.7.0" - } - }, - "@surma/rollup-plugin-off-main-thread": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", - "requires": { - "ejs": "^3.1.6", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" - } - }, - "@svgr/babel-plugin-add-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==" - }, - "@svgr/babel-plugin-remove-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==" - }, - "@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", - "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==" - }, - "@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", - "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==" - }, - "@svgr/babel-plugin-svg-dynamic-title": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", - "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==" - }, - "@svgr/babel-plugin-svg-em-dimensions": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", - "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==" - }, - "@svgr/babel-plugin-transform-react-native-svg": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", - "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==" - }, - "@svgr/babel-plugin-transform-svg-component": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", - "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==" - }, - "@svgr/babel-preset": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", - "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", - "requires": { - "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", - "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", - "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", - "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", - "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", - "@svgr/babel-plugin-transform-svg-component": "^5.5.0" - } - }, - "@svgr/core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", - "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", - "requires": { - "@svgr/plugin-jsx": "^5.5.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^7.0.0" - }, - "dependencies": { - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - } - } - }, - "@svgr/hast-util-to-babel-ast": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", - "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", - "requires": { - "@babel/types": "^7.12.6" - } - }, - "@svgr/plugin-jsx": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", - "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", - "requires": { - "@babel/core": "^7.12.3", - "@svgr/babel-preset": "^5.5.0", - "@svgr/hast-util-to-babel-ast": "^5.5.0", - "svg-parser": "^2.0.2" - } - }, - "@svgr/plugin-svgo": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", - "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", - "requires": { - "cosmiconfig": "^7.0.0", - "deepmerge": "^4.2.2", - "svgo": "^1.2.2" - }, - "dependencies": { - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - } - } - }, - "@svgr/webpack": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", - "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", - "requires": { - "@babel/core": "^7.12.3", - "@babel/plugin-transform-react-constant-elements": "^7.12.1", - "@babel/preset-env": "^7.12.1", - "@babel/preset-react": "^7.12.5", - "@svgr/core": "^5.5.0", - "@svgr/plugin-jsx": "^5.5.0", - "@svgr/plugin-svgo": "^5.5.0", - "loader-utils": "^2.0.0" - } - }, - "@szmarczak/http-timer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", - "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", - "dev": true, - "requires": { - "defer-to-connect": "^1.0.1" - } - }, - "@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" - }, - "@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==" - }, - "@types/babel__core": { - "version": "7.1.19", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", - "integrity": "sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==", - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "requires": { - "@babel/types": "^7.0.0" - } - }, - "@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "@types/babel__traverse": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.17.0.tgz", - "integrity": "sha512-r8aveDbd+rzGP+ykSdF3oPuTVRWRfbBiHl0rVDM2yNEmSMXfkObQLV46b4RnCv3Lra51OlfnZhkkFaDl2MIRaA==", - "requires": { - "@babel/types": "^7.3.0" - } - }, - "@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "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==", - "requires": { - "@types/node": "*" - } - }, - "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "requires": { - "@types/node": "*" - } - }, - "@types/connect-history-api-fallback": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", - "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", - "requires": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "@types/debug": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", - "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", - "dev": true, - "requires": { - "@types/ms": "*" - } - }, - "@types/eslint": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", - "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", - "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==", - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" - }, - "@types/express": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", - "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.17.28", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", - "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@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, - "optional": true, - "requires": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "@types/graceful-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", - "requires": { - "@types/node": "*" - } - }, - "@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" - }, - "@types/http-proxy": { - "version": "1.17.8", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.8.tgz", - "integrity": "sha512-5kPLG5BKpWYkw/LVOGWpiq3nEVqxiN32rTgI53Sk12/xHFQ2rG3ehI9IO+O3W2QoKeyB92dJkoka8SUm6BX1pA==", - "requires": { - "@types/node": "*" - } - }, - "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" - }, - "@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "requires": { - "@types/istanbul-lib-coverage": "*" - } - }, - "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "requires": { - "@types/istanbul-lib-report": "*" - } - }, - "@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" - }, - "@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" - }, - "@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "@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, - "optional": true - }, - "@types/ms": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", - "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", - "dev": true - }, - "@types/node": { - "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.26.tgz", - "integrity": "sha512-z/FG/6DUO7pnze3AE3TBGIjGGKkvCcGcWINe1C7cADY8hKLJPDYpzsNE37uExQ4md5RFtTCvg+M8Mu1Enyeg2A==" - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "@types/plist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz", - "integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==", - "dev": true, - "optional": true, - "requires": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, - "@types/prettier": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.0.tgz", - "integrity": "sha512-G/AdOadiZhnJp0jXCaBQU449W2h716OW/EoXeYkCytxKL06X1WCXB4DZpp8TpZ8eyIJVS1cw4lrlkkSYU21cDw==" - }, - "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" - }, - "@types/q": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", - "integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==" - }, - "@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==" - }, - "@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==" - }, - "@types/react": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.6.tgz", - "integrity": "sha512-bPqwzJRzKtfI0mVYr5R+1o9BOE8UEXefwc1LwcBtfnaAn6OoqMhLa/91VA8aeWfDPJt1kHvYKI8RHcQybZLHHA==", - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/react-is": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz", - "integrity": "sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==", - "requires": { - "@types/react": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "requires": { - "@types/react": "*" - } - }, - "@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "requires": { - "@types/node": "*" - } - }, - "@types/retry": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz", - "integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==" - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, - "@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==", - "requires": { - "@types/express": "*" - } - }, - "@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/sockjs": { - "version": "0.3.33", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", - "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", - "requires": { - "@types/node": "*" - } - }, - "@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" - }, - "@types/trusted-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", - "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" - }, - "@types/verror": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.5.tgz", - "integrity": "sha512-9UjMCHK5GPgQRoNbqdLIAvAy0EInuiqbW0PBMtVP6B5B2HQJlvoJHM+KodPZMEjOa5VkSc+5LH7xy+cUzQdmHw==", - "dev": true, - "optional": true - }, - "@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "requires": { - "@types/node": "*" - } - }, - "@types/yargs": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", - "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", - "requires": { - "@types/yargs-parser": "*" - } - }, - "@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" - }, - "@typescript-eslint/eslint-plugin": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.20.0.tgz", - "integrity": "sha512-fapGzoxilCn3sBtC6NtXZX6+P/Hef7VDbyfGqTTpzYydwhlkevB+0vE0EnmHPVTVSy68GUncyJ/2PcrFBeCo5Q==", - "requires": { - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/type-utils": "5.20.0", - "@typescript-eslint/utils": "5.20.0", - "debug": "^4.3.2", - "functional-red-black-tree": "^1.0.1", - "ignore": "^5.1.8", - "regexpp": "^3.2.0", - "semver": "^7.3.5", - "tsutils": "^3.21.0" - }, - "dependencies": { - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "@typescript-eslint/experimental-utils": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.20.0.tgz", - "integrity": "sha512-w5qtx2Wr9x13Dp/3ic9iGOGmVXK5gMwyc8rwVgZU46K9WTjPZSyPvdER9Ycy+B5lNHvoz+z2muWhUvlTpQeu+g==", - "requires": { - "@typescript-eslint/utils": "5.20.0" - } - }, - "@typescript-eslint/parser": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.20.0.tgz", - "integrity": "sha512-UWKibrCZQCYvobmu3/N8TWbEeo/EPQbS41Ux1F9XqPzGuV7pfg6n50ZrFo6hryynD8qOTTfLHtHjjdQtxJ0h/w==", - "requires": { - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/typescript-estree": "5.20.0", - "debug": "^4.3.2" - } - }, - "@typescript-eslint/scope-manager": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.20.0.tgz", - "integrity": "sha512-h9KtuPZ4D/JuX7rpp1iKg3zOH0WNEa+ZIXwpW/KWmEFDxlA/HSfCMhiyF1HS/drTICjIbpA6OqkAhrP/zkCStg==", - "requires": { - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/visitor-keys": "5.20.0" - } - }, - "@typescript-eslint/type-utils": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.20.0.tgz", - "integrity": "sha512-WxNrCwYB3N/m8ceyoGCgbLmuZwupvzN0rE8NBuwnl7APgjv24ZJIjkNzoFBXPRCGzLNkoU/WfanW0exvp/+3Iw==", - "requires": { - "@typescript-eslint/utils": "5.20.0", - "debug": "^4.3.2", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/types": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.20.0.tgz", - "integrity": "sha512-+d8wprF9GyvPwtoB4CxBAR/s0rpP25XKgnOvMf/gMXYDvlUC3rPFHupdTQ/ow9vn7UDe5rX02ovGYQbv/IUCbg==" - }, - "@typescript-eslint/typescript-estree": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.20.0.tgz", - "integrity": "sha512-36xLjP/+bXusLMrT9fMMYy1KJAGgHhlER2TqpUVDYUQg4w0q/NW/sg4UGAgVwAqb8V4zYg43KMUpM8vV2lve6w==", - "requires": { - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/visitor-keys": "5.20.0", - "debug": "^4.3.2", - "globby": "^11.0.4", - "is-glob": "^4.0.3", - "semver": "^7.3.5", - "tsutils": "^3.21.0" - }, - "dependencies": { - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "@typescript-eslint/utils": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.20.0.tgz", - "integrity": "sha512-lHONGJL1LIO12Ujyx8L8xKbwWSkoUKFSO+0wDAqGXiudWB2EO7WEUT+YZLtVbmOmSllAjLb9tpoIPwpRe5Tn6w==", - "requires": { - "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.20.0", - "@typescript-eslint/types": "5.20.0", - "@typescript-eslint/typescript-estree": "5.20.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" - }, - "dependencies": { - "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==", - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" - } - } - }, - "@typescript-eslint/visitor-keys": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.20.0.tgz", - "integrity": "sha512-1flRpNF+0CAQkMNlTJ6L/Z5jiODG/e5+7mk6XwtPOUS3UrTz3UOiAg9jG2VtKsWI6rZQfy4C6a232QNRZTRGlg==", - "requires": { - "@typescript-eslint/types": "5.20.0", - "eslint-visitor-keys": "^3.0.0" - } - }, - "@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "requires": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" - }, - "@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" - }, - "@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" - }, - "@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" - }, - "@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@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==" - }, - "@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==" - }, - "7zip-bin": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.1.1.tgz", - "integrity": "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ==", - "dev": true - }, - "abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" - }, - "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==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "acorn": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", - "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==" - }, - "acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "requires": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" - } - } - }, - "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==", - "requires": {} - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "requires": {} - }, - "acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "requires": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" - } - } - }, - "acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==" - }, - "address": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.1.2.tgz", - "integrity": "sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==" - }, - "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==", - "requires": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - } - }, - "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==", - "requires": { - "debug": "4" - } - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "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-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "requires": { - "ajv": "^8.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, - "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==" - } - } - }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "requires": {} - }, - "ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "dev": true, - "requires": { - "string-width": "^4.1.0" - } - }, - "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==", - "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==" - }, - "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" - } - }, - "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" - } - }, - "app-builder-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", - "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", - "dev": true - }, - "app-builder-lib": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-23.0.3.tgz", - "integrity": "sha512-1qrtXYHXJfXhzJnMtVGjIva3067F1qYQubl2oBjI61gCBoCHvhghdYJ57XxXTQQ0VxnUhg1/Iaez87uXp8mD8w==", - "dev": true, - "requires": { - "@develar/schema-utils": "~2.6.5", - "@electron/universal": "1.2.0", - "@malept/flatpak-bundler": "^0.4.0", - "7zip-bin": "~5.1.1", - "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.9", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chromium-pickle-js": "^0.2.0", - "debug": "^4.3.2", - "ejs": "^3.1.6", - "electron-osx-sign": "^0.6.0", - "electron-publish": "23.0.2", - "form-data": "^4.0.0", - "fs-extra": "^10.0.0", - "hosted-git-info": "^4.0.2", - "is-ci": "^3.0.0", - "isbinaryfile": "^4.0.8", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "minimatch": "^3.0.4", - "read-config-file": "6.2.0", - "sanitize-filename": "^1.6.3", - "semver": "^7.3.5", - "temp-file": "^3.4.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 - }, - "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" - } - }, - "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" - } - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "requires": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - } - }, - "array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" - }, - "array-includes": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", - "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1", - "get-intrinsic": "^1.1.1", - "is-string": "^1.0.7" - } - }, - "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==" - }, - "array.prototype.flat": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz", - "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.2", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.flatmap": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz", - "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.2", - "es-shim-unscopables": "^1.0.0" - } - }, - "asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" - }, - "asar": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/asar/-/asar-3.1.0.tgz", - "integrity": "sha512-vyxPxP5arcAqN4F/ebHd/HhwnAiZtwhglvdmc7BR2f0ywbVNTOpSeyhLDbGXtE/y58hv1oC75TaNIXutnsOZsQ==", - "dev": true, - "requires": { - "@types/glob": "^7.1.1", - "chromium-pickle-js": "^0.2.0", - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "dependencies": { - "commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true - } - } - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true, - "optional": true - }, - "ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=" - }, - "astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "optional": true - }, - "async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "requires": { - "lodash": "^4.17.14" - } - }, - "async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "dev": true - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" - }, - "autoprefixer": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.5.tgz", - "integrity": "sha512-Fvd8yCoA7lNX/OUllvS+aS1I7WRBclGXsepbvT8ZaPgrH24rgXpZzF0/6Hh3ZEkwg+0AES/Osd196VZmYoEFtw==", - "requires": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001332", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, - "axe-core": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz", - "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==" - }, - "axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", - "dev": true, - "requires": { - "follow-redirects": "^1.14.7" - } - }, - "axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==" - }, - "babel-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", - "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", - "requires": { - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "babel-loader": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", - "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", - "requires": { - "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", - "make-dir": "^3.1.0", - "schema-utils": "^2.6.5" - }, - "dependencies": { - "schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", - "requires": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - } - } - } - }, - "babel-plugin-dynamic-import-node": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", - "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", - "requires": { - "object.assign": "^4.1.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==", - "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-jest-hoist": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", - "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", - "requires": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.0.0", - "@types/babel__traverse": "^7.0.6" - } - }, - "babel-plugin-macros": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", - "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", - "requires": { - "@babel/runtime": "^7.7.2", - "cosmiconfig": "^6.0.0", - "resolve": "^1.12.0" - } - }, - "babel-plugin-named-asset-import": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", - "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", - "requires": {} - }, - "babel-plugin-polyfill-corejs2": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz", - "integrity": "sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==", - "requires": { - "@babel/compat-data": "^7.13.11", - "@babel/helper-define-polyfill-provider": "^0.3.1", - "semver": "^6.1.1" - } - }, - "babel-plugin-polyfill-corejs3": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz", - "integrity": "sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==", - "requires": { - "@babel/helper-define-polyfill-provider": "^0.3.1", - "core-js-compat": "^3.21.0" - } - }, - "babel-plugin-polyfill-regenerator": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz", - "integrity": "sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==", - "requires": { - "@babel/helper-define-polyfill-provider": "^0.3.1" - } - }, - "babel-plugin-transform-react-remove-prop-types": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", - "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==" - }, - "babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "requires": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@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-top-level-await": "^7.8.3" - } - }, - "babel-preset-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", - "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", - "requires": { - "babel-plugin-jest-hoist": "^27.5.1", - "babel-preset-current-node-syntax": "^1.0.0" - } - }, - "babel-preset-react-app": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", - "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", - "requires": { - "@babel/core": "^7.16.0", - "@babel/plugin-proposal-class-properties": "^7.16.0", - "@babel/plugin-proposal-decorators": "^7.16.4", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", - "@babel/plugin-proposal-numeric-separator": "^7.16.0", - "@babel/plugin-proposal-optional-chaining": "^7.16.0", - "@babel/plugin-proposal-private-methods": "^7.16.0", - "@babel/plugin-transform-flow-strip-types": "^7.16.0", - "@babel/plugin-transform-react-display-name": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.4", - "@babel/preset-env": "^7.16.4", - "@babel/preset-react": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", - "@babel/runtime": "^7.16.3", - "babel-plugin-macros": "^3.1.0", - "babel-plugin-transform-react-remove-prop-types": "^0.4.24" - }, - "dependencies": { - "babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "requires": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - } - }, - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - } - } - }, - "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 - }, - "batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=" - }, - "bfj": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", - "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", - "requires": { - "bluebird": "^3.5.5", - "check-types": "^11.1.1", - "hoopy": "^0.1.4", - "tryer": "^1.0.1" - } - }, - "big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" - }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "bluebird-lst": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", - "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", - "dev": true, - "requires": { - "bluebird": "^3.5.5" - } - }, - "body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.8.1", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.9.7", - "raw-body": "2.4.3", - "type-is": "~1.6.18" - }, - "dependencies": { - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "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==", - "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=" - } - } - }, - "bonjour-service": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", - "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", - "requires": { - "array-flatten": "^2.1.2", - "dns-equal": "^1.0.0", - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.4" - } - }, - "boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" - }, - "boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "dev": true, - "optional": true - }, - "boxen": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", - "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", - "dev": true, - "requires": { - "ansi-align": "^3.0.0", - "camelcase": "^6.2.0", - "chalk": "^4.1.0", - "cli-boxes": "^2.2.1", - "string-width": "^4.2.2", - "type-fest": "^0.20.2", - "widest-line": "^3.1.0", - "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" - } - }, - "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 - } - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "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" - } - }, - "browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" - }, - "browserslist": { - "version": "4.20.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", - "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", - "requires": { - "caniuse-lite": "^1.0.30001332", - "electron-to-chromium": "^1.4.118", - "escalade": "^3.1.1", - "node-releases": "^2.0.3", - "picocolors": "^1.0.0" - } - }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "requires": { - "node-int64": "^0.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "optional": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "requires": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true - }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", - "dev": true - }, - "buffer-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", - "integrity": "sha1-WWFrSYME1Var1GaWayLu2j7KX74=", - "dev": true - }, - "buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "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==" - }, - "builder-util": { - "version": "23.0.2", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-23.0.2.tgz", - "integrity": "sha512-HaNHL3axNW/Ms8O1mDx3I07G+ZnZ/TKSWWvorOAPau128cdt9S+lNx5ocbx8deSaHHX4WFXSZVHh3mxlaKJNgg==", - "dev": true, - "requires": { - "@types/debug": "^4.1.6", - "@types/fs-extra": "^9.0.11", - "7zip-bin": "~5.1.1", - "app-builder-bin": "4.0.0", - "bluebird-lst": "^1.0.9", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "cross-spawn": "^7.0.3", - "debug": "^4.3.2", - "fs-extra": "^10.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-ci": "^3.0.0", - "js-yaml": "^4.1.0", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0" - }, - "dependencies": { - "@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 - }, - "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 - }, - "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 - }, - "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" - } - }, - "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" - } - }, - "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" - } - } - } - }, - "builder-util-runtime": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.0.0.tgz", - "integrity": "sha512-SkpEtSmTkREDHRJnxKEv43aAYp8sYWY8fxYBhGLBLOBIRXeaIp6Kv3lBgSD7uR8jQtC7CA659sqJrpSV6zNvSA==", - "dev": true, - "requires": { - "debug": "^4.3.2", - "sax": "^1.2.4" - } - }, - "builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==" - }, - "bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" - }, - "cacheable-request": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", - "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", - "dev": true, - "requires": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^3.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^4.1.0", - "responselike": "^1.0.2" - }, - "dependencies": { - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true - }, - "normalize-url": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", - "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", - "dev": true - } - } - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "requires": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" - }, - "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==" - }, - "caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "requires": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "caniuse-lite": { - "version": "1.0.30001332", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz", - "integrity": "sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw==" - }, - "case-sensitive-paths-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==" - }, - "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" - }, - "dependencies": { - "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=" - } - } - }, - "char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==" - }, - "charcodes": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/charcodes/-/charcodes-0.2.0.tgz", - "integrity": "sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ==" - }, - "check-types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz", - "integrity": "sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==" - }, - "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" - }, - "dependencies": { - "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" - } - } - } - }, - "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==" - }, - "chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha1-BKEGZywYsIWrd02YPfo+oTjyIgU=", - "dev": true - }, - "ci-info": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", - "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" - }, - "cjs-module-lexer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" - }, - "clean-css": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", - "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", - "requires": { - "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==" - } - } - }, - "cli-boxes": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", - "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", - "dev": true - }, - "cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "optional": true, - "requires": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.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==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "clone-response": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", - "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", - "dev": true, - "requires": { - "mimic-response": "^1.0.0" - } - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" - }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" - }, - "coa": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", - "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", - "requires": { - "@types/q": "^1.5.1", - "chalk": "^2.4.1", - "q": "^1.1.2" - } - }, - "collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" - }, - "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=" - }, - "colord": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz", - "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==" - }, - "colorette": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", - "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==" - }, - "colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", - "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==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" - }, - "common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" - }, - "common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==" - }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" - }, - "compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha1-AWLsLZNR9d3VmpICy6k1NmpyUIA=", - "dev": true - }, - "compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "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==", - "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": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - }, - "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" - } - } - } - }, - "concurrently": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.1.0.tgz", - "integrity": "sha512-Bz0tMlYKZRUDqJlNiF/OImojMB9ruKUz6GCfmhFnSapXgPe+3xzY4byqoKG9tUZ7L2PGEUjfLPOLfIX3labnmw==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "date-fns": "^2.16.1", - "lodash": "^4.17.21", - "rxjs": "^6.6.3", - "spawn-command": "^0.0.2-1", - "supports-color": "^8.1.0", - "tree-kill": "^1.2.2", - "yargs": "^16.2.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" - }, - "dependencies": { - "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" - } - } - } - }, - "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": "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" - } - } - } - }, - "config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "optional": true, - "requires": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "configstore": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", - "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", - "dev": true, - "requires": { - "dot-prop": "^5.2.0", - "graceful-fs": "^4.1.2", - "make-dir": "^3.0.0", - "unique-string": "^2.0.0", - "write-file-atomic": "^3.0.0", - "xdg-basedir": "^4.0.0" - } - }, - "confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==" - }, - "connect-history-api-fallback": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", - "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==" - }, - "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==", - "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==" - } - } - }, - "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==" - }, - "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==" - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" - }, - "core-js": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.2.tgz", - "integrity": "sha512-Z5I2vzDnEIqO2YhELVMFcL1An2CIsFe9Q7byZhs8c/QxummxZlAHw33TUHbIte987LkisOgL0LwQ1P9D6VISnA==" - }, - "core-js-compat": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.2.tgz", - "integrity": "sha512-Fns9lU06ZJ07pdfmPMu7OnkIKGPKDzXKIiuGlSvHHapwqMUF2QnnsWwtueFZtSyZEilP0o6iUeHQwpn7LxtLUw==", - "requires": { - "browserslist": "^4.20.2", - "semver": "7.0.0" - }, - "dependencies": { - "semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==" - } - } - }, - "core-js-pure": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.2.tgz", - "integrity": "sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ==" - }, - "core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - } - }, - "crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "optional": true, - "requires": { - "buffer": "^5.1.0" - } - }, - "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "requires": { - "node-fetch": "2.6.7" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" - }, - "css-blank-pseudo": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", - "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", - "requires": { - "postcss-selector-parser": "^6.0.9" - } - }, - "css-declaration-sorter": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz", - "integrity": "sha512-Ufadglr88ZLsrvS11gjeu/40Lw74D9Am/Jpr3LlYm5Q4ZP5KdlUhG+6u2EjyXeZcxmZ2h1ebCKngDjolpeLHpg==", - "requires": {} - }, - "css-has-pseudo": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", - "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", - "requires": { - "postcss-selector-parser": "^6.0.9" - } - }, - "css-loader": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", - "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", - "requires": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.7", - "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.5" - }, - "dependencies": { - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "css-minimizer-webpack-plugin": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", - "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", - "requires": { - "cssnano": "^5.0.6", - "jest-worker": "^27.0.2", - "postcss": "^8.3.5", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "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==" - }, - "schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "css-prefers-color-scheme": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", - "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", - "requires": {} - }, - "css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "requires": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - } - }, - "css-select-base-adapter": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", - "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" - }, - "css-tree": { - "version": "1.0.0-alpha.37", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", - "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", - "requires": { - "mdn-data": "2.0.4", - "source-map": "^0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" - }, - "cssdb": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-6.5.0.tgz", - "integrity": "sha512-Rh7AAopF2ckPXe/VBcoUS9JrCZNSyc60+KpgE6X25vpVxA32TmiqvExjkfhwP4wGSb6Xe8Z/JIyGqwgx/zZYFA==" - }, - "cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" - }, - "cssnano": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.7.tgz", - "integrity": "sha512-pVsUV6LcTXif7lvKKW9ZrmX+rGRzxkEdJuVJcp5ftUjWITgwam5LMZOgaTvUrWPkcORBey6he7JKb4XAJvrpKg==", - "requires": { - "cssnano-preset-default": "^5.2.7", - "lilconfig": "^2.0.3", - "yaml": "^1.10.2" - } - }, - "cssnano-preset-default": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.7.tgz", - "integrity": "sha512-JiKP38ymZQK+zVKevphPzNSGHSlTI+AOwlasoSRtSVMUU285O7/6uZyd5NbW92ZHp41m0sSHe6JoZosakj63uA==", - "requires": { - "css-declaration-sorter": "^6.2.2", - "cssnano-utils": "^3.1.0", - "postcss-calc": "^8.2.3", - "postcss-colormin": "^5.3.0", - "postcss-convert-values": "^5.1.0", - "postcss-discard-comments": "^5.1.1", - "postcss-discard-duplicates": "^5.1.0", - "postcss-discard-empty": "^5.1.1", - "postcss-discard-overridden": "^5.1.0", - "postcss-merge-longhand": "^5.1.4", - "postcss-merge-rules": "^5.1.1", - "postcss-minify-font-values": "^5.1.0", - "postcss-minify-gradients": "^5.1.1", - "postcss-minify-params": "^5.1.2", - "postcss-minify-selectors": "^5.2.0", - "postcss-normalize-charset": "^5.1.0", - "postcss-normalize-display-values": "^5.1.0", - "postcss-normalize-positions": "^5.1.0", - "postcss-normalize-repeat-style": "^5.1.0", - "postcss-normalize-string": "^5.1.0", - "postcss-normalize-timing-functions": "^5.1.0", - "postcss-normalize-unicode": "^5.1.0", - "postcss-normalize-url": "^5.1.0", - "postcss-normalize-whitespace": "^5.1.1", - "postcss-ordered-values": "^5.1.1", - "postcss-reduce-initial": "^5.1.0", - "postcss-reduce-transforms": "^5.1.0", - "postcss-svgo": "^5.1.0", - "postcss-unique-selectors": "^5.1.1" - } - }, - "cssnano-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", - "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", - "requires": {} - }, - "csso": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", - "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", - "requires": { - "css-tree": "^1.1.2" - }, - "dependencies": { - "css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", - "requires": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - } - }, - "mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" - }, - "cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "requires": { - "cssom": "~0.3.6" - }, - "dependencies": { - "cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" - } - } - }, - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" - }, - "data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", - "requires": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" - }, - "dependencies": { - "tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", - "requires": { - "punycode": "^2.1.1" - } - }, - "whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", - "requires": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - } - } - } - }, - "date-fns": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", - "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", - "dev": 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" - } - }, - "decimal.js": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", - "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==" - }, - "decompress-response": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", - "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", - "dev": true, - "requires": { - "mimic-response": "^1.0.0" - } - }, - "dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=" - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "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==" - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" - }, - "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==", - "requires": { - "execa": "^5.0.0" - } - }, - "defer-to-connect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", - "dev": true - }, - "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==" - }, - "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "requires": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" - }, - "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" - }, - "detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==" - }, - "detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" - }, - "detect-port-alt": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", - "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", - "requires": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "requires": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - } - }, - "dexie": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.1.tgz", - "integrity": "sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g==" - }, - "dexie-react-hooks": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.1.tgz", - "integrity": "sha512-Cam5JP6PxHN564RvWEoe8cqLhosW0O4CAZ9XEVYeGHJBa6KEJlOpd9CUpV3kmU9dm2MrW97/lk7qkf1xpij7gA==", - "requires": {} - }, - "didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==" - }, - "dir-compare": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-2.4.0.tgz", - "integrity": "sha512-l9hmu8x/rjVC9Z2zmGzkhOEowZvW7pmYws5CWHutg8u1JgvsKWMx7Q/UODeu4djLZ4FgW5besw5yvMQnBHzuCA==", - "dev": true, - "requires": { - "buffer-equal": "1.0.0", - "colors": "1.0.3", - "commander": "2.9.0", - "minimatch": "3.0.4" - }, - "dependencies": { - "commander": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", - "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", - "dev": true, - "requires": { - "graceful-readlink": ">= 1.0.0" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - } - } - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "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==" - }, - "dmg-builder": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-23.0.3.tgz", - "integrity": "sha512-mBYrHHnSM5PC656TDE+xTGmXIuWHAGmmRfyM+dV0kP+AxtwPof4pAXNQ8COd0/exZQ4dqf72FiPS3B9G9aB5IA==", - "dev": true, - "requires": { - "app-builder-lib": "23.0.3", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "dmg-license": "^1.0.9", - "fs-extra": "^10.0.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.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" - } - } - } - }, - "dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "optional": true, - "requires": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - } - }, - "dns-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=" - }, - "dns-packet": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz", - "integrity": "sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==", - "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==", - "requires": { - "esutils": "^2.0.2" - } - }, - "dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "requires": { - "utila": "~0.4" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "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==" - }, - "domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", - "requires": { - "webidl-conversions": "^5.0.0" - }, - "dependencies": { - "webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==" - } - } - }, - "domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "requires": { - "domelementtype": "^2.2.0" - } - }, - "domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "requires": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - } - }, - "dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "requires": { - "is-obj": "^2.0.0" - }, - "dependencies": { - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - } - } - }, - "dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" - }, - "dotenv-expand": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", - "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" - }, - "duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" - }, - "duplexer3": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", - "dev": true - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" - }, - "ejs": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz", - "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==", - "requires": { - "jake": "^10.8.5" - } - }, - "electron": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-18.2.0.tgz", - "integrity": "sha512-AN+CKalzA57beuvuI90PVgW/yj6zjw7rpb1h8FvIwBJ3toDC3x0Plfzbzh4Ondecbjci7pSg/NA5ngOk804WIQ==", - "dev": true, - "requires": { - "@electron/get": "^1.13.0", - "@types/node": "^16.11.26", - "extract-zip": "^1.0.3" - }, - "dependencies": { - "@types/node": { - "version": "16.11.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.33.tgz", - "integrity": "sha512-0PJ0vg+JyU0MIan58IOIFRtSvsb7Ri+7Wltx2qAg94eMOrpg4+uuP3aUHCpxXc1i0jCXiC+zIamSZh3l9AbcQA==", - "dev": true - } - } - }, - "electron-builder": { - "version": "23.0.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-23.0.3.tgz", - "integrity": "sha512-0lnTsljAgcOMuIiOjPcoFf+WxOOe/O04hZPgIvvUBXIbz3kolbNu0Xdch1f5WuQ40NdeZI7oqs8Eo395PcuGHQ==", - "dev": true, - "requires": { - "@types/yargs": "^17.0.1", - "app-builder-lib": "23.0.3", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "dmg-builder": "23.0.3", - "fs-extra": "^10.0.0", - "is-ci": "^3.0.0", - "lazy-val": "^1.0.5", - "read-config-file": "6.2.0", - "update-notifier": "^5.1.0", - "yargs": "^17.0.1" - }, - "dependencies": { - "@types/yargs": { - "version": "17.0.10", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", - "integrity": "sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, - "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" - } - }, - "yargs": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.4.1.tgz", - "integrity": "sha512-WSZD9jgobAg3ZKuCQZSa3g9QOJeCCqLoLAykiWgmXnDo9EPnn4RPf5qVTtzgOx66o6/oqhcA5tHtJXpG8pMt3g==", - "dev": 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.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - } - }, - "yargs-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", - "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==", - "dev": true - } - } - }, - "electron-is-dev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-2.0.0.tgz", - "integrity": "sha512-3X99K852Yoqu9AcW50qz3ibYBWY79/pBhlMCab8ToEWS48R0T9tyxRiQhwylE7zQdXrMnx2JKqUJyMPmt5FBqA==" - }, - "electron-osx-sign": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.6.0.tgz", - "integrity": "sha512-+hiIEb2Xxk6eDKJ2FFlpofCnemCbjbT5jz+BKGpVBrRNT3kWTGs4DfNX6IzGwgi33hUcXF+kFs9JW+r6Wc1LRg==", - "dev": true, - "requires": { - "bluebird": "^3.5.0", - "compare-version": "^0.1.2", - "debug": "^2.6.8", - "isbinaryfile": "^3.0.2", - "minimist": "^1.2.0", - "plist": "^3.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" - } - }, - "isbinaryfile": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.3.tgz", - "integrity": "sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==", - "dev": true, - "requires": { - "buffer-alloc": "^1.2.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "electron-publish": { - "version": "23.0.2", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-23.0.2.tgz", - "integrity": "sha512-8gMYgWqv96lc83FCm85wd+tEyxNTJQK7WKyPkNkO8GxModZqt1GO8S+/vAnFGxilS/7vsrVRXFfqiCDUCSuxEg==", - "dev": true, - "requires": { - "@types/fs-extra": "^9.0.11", - "builder-util": "23.0.2", - "builder-util-runtime": "9.0.0", - "chalk": "^4.1.1", - "fs-extra": "^10.0.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.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 - }, - "mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "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" - } - } - } - }, - "electron-to-chromium": { - "version": "1.4.118", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.118.tgz", - "integrity": "sha512-maZIKjnYDvF7Fs35nvVcyr44UcKNwybr93Oba2n3HkKDFAtk0svERkLN/HyczJDS3Fo4wU9th9fUQd09ZLtj1w==" - }, - "emittery": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", - "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==" - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" - }, - "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" - } - }, - "enhanced-resolve": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz", - "integrity": "sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==", - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, - "entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" - }, - "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 - }, - "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==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "error-stack-parser": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.7.tgz", - "integrity": "sha512-chLOW0ZGRf4s8raLrDxa5sdkvPec5YdvwbFnqJme4rk0rFajP8mPtrDL1+I+CwrQDCjswDA5sREX7jYQDQs9vA==", - "requires": { - "stackframe": "^1.1.1" - } - }, - "es-abstract": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz", - "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==", - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - } - }, - "es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" - }, - "es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "requires": { - "has": "^1.0.3" - } - }, - "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==", - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "optional": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "escape-goat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", - "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", - "dev": true - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" - }, - "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==" - }, - "escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "requires": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" - }, - "dependencies": { - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "requires": { - "prelude-ls": "~1.1.2" - } - } - } - }, - "eslint": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.14.0.tgz", - "integrity": "sha512-3/CE4aJX7LNEiE3i6FeodHmI/38GZtWCsAtsymScmzYapx8q1nVVb+eLcLSzATmCPXw5pT4TqVs1E0OmxAd9tw==", - "requires": { - "@eslint/eslintrc": "^1.2.2", - "@humanwhocodes/config-array": "^0.9.2", - "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.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "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" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "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==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "requires": { - "argparse": "^2.0.1" - } - }, - "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==", - "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==" - } - } - }, - "eslint-config-react-app": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", - "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", - "requires": { - "@babel/core": "^7.16.0", - "@babel/eslint-parser": "^7.16.3", - "@rushstack/eslint-patch": "^1.1.0", - "@typescript-eslint/eslint-plugin": "^5.5.0", - "@typescript-eslint/parser": "^5.5.0", - "babel-preset-react-app": "^10.0.1", - "confusing-browser-globals": "^1.0.11", - "eslint-plugin-flowtype": "^8.0.3", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jest": "^25.3.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.27.1", - "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-testing-library": "^5.0.1" - } - }, - "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", - "requires": { - "debug": "^3.2.7", - "resolve": "^1.20.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", - "requires": { - "debug": "^3.2.7", - "find-up": "^2.1.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "requires": { - "ms": "^2.1.1" - } - }, - "find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "requires": { - "locate-path": "^2.0.0" - } - }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "requires": { - "p-try": "^1.0.0" - } - }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "requires": { - "p-limit": "^1.1.0" - } - }, - "p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" - } - } - }, - "eslint-plugin-flowtype": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", - "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", - "requires": { - "lodash": "^4.17.21", - "string-natural-compare": "^3.0.1" - } - }, - "eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", - "requires": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "requires": { - "esutils": "^2.0.2" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "eslint-plugin-jest": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", - "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", - "requires": { - "@typescript-eslint/experimental-utils": "^5.0.0" - } - }, - "eslint-plugin-jsx-a11y": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", - "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", - "requires": { - "@babel/runtime": "^7.16.3", - "aria-query": "^4.2.2", - "array-includes": "^3.1.4", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.3.5", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.7", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.2.1", - "language-tags": "^1.0.5", - "minimatch": "^3.0.4" - } - }, - "eslint-plugin-react": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.29.4.tgz", - "integrity": "sha512-CVCXajliVh509PcZYRFyu/BoUEz452+jtQJq2b3Bae4v3xBUWPLCmtmBM+ZinG4MzwmxJgJ2M5rMqhqLVn7MtQ==", - "requires": { - "array-includes": "^3.1.4", - "array.prototype.flatmap": "^1.2.5", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.5", - "object.fromentries": "^2.0.5", - "object.hasown": "^1.1.0", - "object.values": "^1.1.5", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.6" - }, - "dependencies": { - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "requires": { - "esutils": "^2.0.2" - } - }, - "resolve": { - "version": "2.0.0-next.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz", - "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==", - "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - } - } - } - }, - "eslint-plugin-react-hooks": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.4.0.tgz", - "integrity": "sha512-U3RVIfdzJaeKDQKEJbz5p3NW8/L80PCATJAfuojwbaEL+gBjfGdhUcGde+WGUW46Q5sr/NgxevsIiDtNXrvZaQ==", - "requires": {} - }, - "eslint-plugin-testing-library": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.3.1.tgz", - "integrity": "sha512-OfF4dlG/q6ck6DL3P8Z0FPdK0dU5K57gsBu7eUcaVbwYKaNzjgejnXiM9CCUevppORkvfek+9D3Uj/9ZZ8Vz8g==", - "requires": { - "@typescript-eslint/utils": "^5.13.0" - } - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==" - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==" - }, - "eslint-webpack-plugin": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.1.1.tgz", - "integrity": "sha512-xSucskTN9tOkfW7so4EaiFIkulWLXwCB/15H917lR6pTv0Zot6/fetFucmENRb7J5whVSFKIvwnrnsa78SG2yg==", - "requires": { - "@types/eslint": "^7.28.2", - "jest-worker": "^27.3.1", - "micromatch": "^4.0.4", - "normalize-path": "^3.0.0", - "schema-utils": "^3.1.1" - } - }, - "espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", - "requires": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "requires": { - "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==" - }, - "estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" - }, - "eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" - }, - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "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=" - }, - "expect": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", - "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", - "requires": { - "@jest/types": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1" - } - }, - "express": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz", - "integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.19.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.4.2", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~1.1.2", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.1.2", - "fresh": "0.5.2", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.9.7", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.17.2", - "serve-static": "1.14.2", - "setprototypeof": "1.2.0", - "statuses": "~1.5.0", - "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": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "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==" - } - } - }, - "extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", - "dev": true, - "requires": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", - "yauzl": "^2.10.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" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "optional": 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==" - }, - "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "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" - }, - "dependencies": { - "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" - } - } - } - }, - "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==" - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "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==", - "requires": { - "websocket-driver": ">=0.5.1" - } - }, - "fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", - "requires": { - "bser": "2.1.1" - } - }, - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", - "dev": true, - "requires": { - "pend": "~1.2.0" - } - }, - "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==", - "requires": { - "flat-cache": "^3.0.4" - } - }, - "file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "requires": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - } - }, - "filelist": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz", - "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==", - "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==", - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==" - }, - "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==", - "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==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "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==", - "requires": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - } - }, - "find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "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==", - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==" - }, - "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" - }, - "fork-ts-checker-webpack-plugin": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.1.tgz", - "integrity": "sha512-x1wumpHOEf4gDROmKTaB6i4/Q6H3LwmjVO7fIX47vBwlZbtPjU33hgoMuD/Q/y6SU8bnuYSoN6ZQOLshGp0T/g==", - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.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==" - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "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==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" - } - } - }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "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==" - }, - "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": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" - }, - "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==", - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.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==" - }, - "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.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" - }, - "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==" - }, - "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.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" - } - }, - "get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" - }, - "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==" - }, - "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==" - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "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": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - }, - "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==" - }, - "global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "requires": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "dependencies": { - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "global-dirs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", - "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", - "dev": true, - "requires": { - "ini": "2.0.0" - }, - "dependencies": { - "ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true - } - } - }, - "global-modules": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "requires": { - "global-prefix": "^3.0.0" - } - }, - "global-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "requires": { - "ini": "^1.3.5", - "kind-of": "^6.0.2", - "which": "^1.3.1" - }, - "dependencies": { - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "global-tunnel-ng": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz", - "integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==", - "dev": true, - "optional": true, - "requires": { - "encodeurl": "^1.0.2", - "lodash": "^4.17.10", - "npm-conf": "^1.1.3", - "tunnel": "^0.0.6" - } - }, - "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.2", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz", - "integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==", - "dev": true, - "optional": true, - "requires": { - "define-properties": "^1.1.3" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "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" - } - }, - "got": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", - "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", - "dev": true, - "requires": { - "@sindresorhus/is": "^0.14.0", - "@szmarczak/http-timer": "^1.1.2", - "cacheable-request": "^6.0.0", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^4.1.0", - "lowercase-keys": "^1.0.1", - "mimic-response": "^1.0.1", - "p-cancelable": "^1.0.0", - "to-readable-stream": "^1.0.0", - "url-parse-lax": "^3.0.0" - }, - "dependencies": { - "get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - } - } - }, - "graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" - }, - "graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "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==", - "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==" - }, - "harmony-reflect": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", - "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "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==" - }, - "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.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "requires": { - "get-intrinsic": "^1.1.1" - } - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "requires": { - "has-symbols": "^1.0.2" - } - }, - "has-yarn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", - "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", - "dev": true - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" - }, - "history": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", - "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", - "requires": { - "@babel/runtime": "^7.7.6" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - } - } - }, - "hoopy": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", - "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==" - }, - "hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", - "requires": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.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==", - "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==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", - "requires": { - "whatwg-encoding": "^1.0.5" - } - }, - "html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" - }, - "html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "requires": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - } - }, - "html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "requires": { - "void-elements": "3.1.0" - } - }, - "html-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", - "requires": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - } - }, - "htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "requires": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "dev": true - }, - "http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" - }, - "http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - } - }, - "http-parser-js": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.6.tgz", - "integrity": "sha512-vDlkRPDJn93swjcjqMSaGSPABbIarsr1TLAui/gLDXzV5VsJNdXNzMYDyNBLQkjWQCJ1uizu8T2oDMhmGt0PRA==" - }, - "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==", - "requires": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - } - }, - "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "requires": { - "@tootallnate/once": "1", - "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==", - "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" - } - }, - "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==", - "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==" - }, - "i18next": { - "version": "21.6.16", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz", - "integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==", - "requires": { - "@babel/runtime": "^7.17.2" - } - }, - "i18next-browser-languagedetector": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", - "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", - "requires": { - "@babel/runtime": "^7.14.6" - } - }, - "i18next-http-backend": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.0.tgz", - "integrity": "sha512-wsvx7E/CT1pHmBM99Vu57YLJpsrHbVjxGxf25EIJ/6oTjsvCkZZ6c3SA4TejcK5jIHfv9oLxQX8l+DFKZHZ0Gg==", - "requires": { - "cross-fetch": "3.1.5" - } - }, - "iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "optional": true, - "requires": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - } - }, - "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==", - "requires": {} - }, - "idb": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/idb/-/idb-6.1.5.tgz", - "integrity": "sha512-IJtugpKkiVXQn5Y+LteyBCNk1N8xpGV3wWZk9EVtZWH8DYkjBn0bX1XnGP9RkyZF0sAcywa6unHqSWKe7q4LGw==" - }, - "identity-obj-proxy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", - "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=", - "requires": { - "harmony-reflect": "^1.4.6" - } - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "optional": true - }, - "ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" - }, - "immer": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", - "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==" - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "import-lazy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", - "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", - "dev": true - }, - "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" - }, - "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": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "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==" - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "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==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", - "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==" - }, - "is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "requires": { - "ci-info": "^3.2.0" - } - }, - "is-core-module": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", - "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", - "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==", - "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==" - }, - "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-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==" - }, - "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-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "requires": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - } - }, - "is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=" - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==" - }, - "is-npm": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz", - "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==", - "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==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" - }, - "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 - }, - "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==" - }, - "is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" - }, - "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==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=" - }, - "is-root": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", - "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==" - }, - "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "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==", - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "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==", - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "requires": { - "is-docker": "^2.0.0" - } - }, - "is-yarn-global": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", - "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", - "dev": true - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, - "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==" - }, - "istanbul-lib-instrument": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz", - "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==", - "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" - } - }, - "istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.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==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "istanbul-reports": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", - "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "jake": { - "version": "10.8.5", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", - "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", - "requires": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.1", - "minimatch": "^3.0.4" - }, - "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" - } - }, - "async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", - "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", - "requires": { - "@jest/core": "^27.5.1", - "import-local": "^3.0.2", - "jest-cli": "^27.5.1" - } - }, - "jest-changed-files": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", - "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", - "requires": { - "@jest/types": "^27.5.1", - "execa": "^5.0.0", - "throat": "^6.0.1" - } - }, - "jest-circus": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", - "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3", - "throat": "^6.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-cli": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", - "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", - "requires": { - "@jest/core": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "prompts": "^2.0.1", - "yargs": "^16.2.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-config": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", - "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", - "requires": { - "@babel/core": "^7.8.0", - "@jest/test-sequencer": "^27.5.1", - "@jest/types": "^27.5.1", - "babel-jest": "^27.5.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.1", - "graceful-fs": "^4.2.9", - "jest-circus": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-jasmine2": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-docblock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", - "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", - "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", - "requires": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-environment-jsdom": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", - "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1", - "jsdom": "^16.6.0" - } - }, - "jest-environment-node": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", - "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - } - }, - "jest-get-type": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", - "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==" - }, - "jest-haste-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", - "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", - "requires": { - "@jest/types": "^27.5.1", - "@types/graceful-fs": "^4.1.2", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^27.5.1", - "jest-serializer": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "micromatch": "^4.0.4", - "walker": "^1.0.7" - } - }, - "jest-jasmine2": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", - "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "throat": "^6.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-leak-detector": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", - "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", - "requires": { - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - } - }, - "jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-message-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", - "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", - "requires": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^27.5.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-mock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", - "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", - "requires": { - "@jest/types": "^27.5.1", - "@types/node": "*" - } - }, - "jest-pnp-resolver": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", - "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "requires": {} - }, - "jest-regex-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", - "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==" - }, - "jest-resolve": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", - "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", - "requires": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-resolve-dependencies": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", - "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", - "requires": { - "@jest/types": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-snapshot": "^27.5.1" - } - }, - "jest-runner": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", - "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", - "requires": { - "@jest/console": "^27.5.1", - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-leak-detector": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "source-map-support": "^0.5.6", - "throat": "^6.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", - "requires": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-serializer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", - "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", - "requires": { - "@types/node": "*", - "graceful-fs": "^4.2.9" - } - }, - "jest-snapshot": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "requires": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "requires": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-validate": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", - "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", - "requires": { - "@jest/types": "^27.5.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "leven": "^3.1.0", - "pretty-format": "^27.5.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-watch-typeahead": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.0.0.tgz", - "integrity": "sha512-jxoszalAb394WElmiJTFBMzie/RDCF+W7Q29n5LzOPtcoQoHWfdUtHFkbhgf5NwWe8uMOxvKb/g7ea7CshfkTw==", - "requires": { - "ansi-escapes": "^4.3.1", - "chalk": "^4.0.0", - "jest-regex-util": "^27.0.0", - "jest-watcher": "^27.0.0", - "slash": "^4.0.0", - "string-length": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" - }, - "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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "char-regex": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", - "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==" - }, - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==" - }, - "string-length": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", - "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", - "requires": { - "char-regex": "^2.0.0", - "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==", - "requires": { - "ansi-regex": "^6.0.1" - } - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-watcher": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", - "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", - "requires": { - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "jest-util": "^27.5.1", - "string-length": "^4.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==", - "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==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "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==" - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "joi": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", - "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.0", - "@sideway/pinpoint": "^2.0.0" - } - }, - "js-base64": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", - "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==" - }, - "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==", - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "jsdom": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", - "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", - "requires": { - "abab": "^2.0.5", - "acorn": "^8.2.4", - "acorn-globals": "^6.0.0", - "cssom": "^0.4.4", - "cssstyle": "^2.3.0", - "data-urls": "^2.0.0", - "decimal.js": "^10.2.1", - "domexception": "^2.0.1", - "escodegen": "^2.0.0", - "form-data": "^3.0.0", - "html-encoding-sniffer": "^2.0.1", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "6.0.1", - "saxes": "^5.0.1", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^2.0.0", - "webidl-conversions": "^6.1.0", - "whatwg-encoding": "^1.0.5", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.5.0", - "ws": "^7.4.6", - "xml-name-validator": "^3.0.0" - }, - "dependencies": { - "tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", - "requires": { - "punycode": "^2.1.1" - } - }, - "whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", - "requires": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - } - } - } - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" - }, - "json-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", - "dev": true - }, - "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==" - }, - "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==" - }, - "json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "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==" - }, - "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": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" - }, - "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, - "optional": true - }, - "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "jsonpointer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.0.tgz", - "integrity": "sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg==" - }, - "jsx-ast-utils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz", - "integrity": "sha512-HDAyJ4MNQBboGpUnHAVUNJs6X0lh058s6FuixsFGP7MgJYpD6Vasd6nzSG5iIfXu1zAYlHJ/zsOKNlrenTUBnw==", - "requires": { - "array-includes": "^3.1.4", - "object.assign": "^4.1.2" - } - }, - "keyv": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", - "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", - "dev": true, - "requires": { - "json-buffer": "3.0.0" - } - }, - "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==" - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" - }, - "klona": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", - "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==" - }, - "language-subtag-registry": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", - "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==" - }, - "language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=", - "requires": { - "language-subtag-registry": "~0.3.2" - } - }, - "latest-version": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", - "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", - "dev": true, - "requires": { - "package-json": "^6.3.0" - } - }, - "lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true - }, - "leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==" - }, - "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==" - }, - "loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==" - }, - "loader-utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", - "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - }, - "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==", - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" - }, - "lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" - }, - "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==" - }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" - }, - "lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "requires": { - "tslib": "^2.0.3" - } - }, - "lowercase-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true - }, - "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.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "requires": { - "sourcemap-codec": "^1.4.8" - } - }, - "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==", - "requires": { - "semver": "^6.0.0" - } - }, - "makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "requires": { - "tmpl": "1.0.5" - } - }, - "matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "requires": { - "escape-string-regexp": "^4.0.0" - } - }, - "mdn-data": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", - "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" - }, - "memfs": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.1.tgz", - "integrity": "sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==", - "requires": { - "fs-monkey": "1.0.3" - } - }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" - }, - "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==" - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" - }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" - }, - "mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true - }, - "mini-css-extract-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz", - "integrity": "sha512-ndG8nxCEnAemsg4FSgS+yNyHKgkTB4nPKqCOgh65j3/30qqC5RaSQQXMm++Y6sb6E1zRSxPkztj9fqxhS1Eo6w==", - "requires": { - "schema-utils": "^4.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "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==" - }, - "schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.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==" - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "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==" - }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "requires": { - "minimist": "^1.2.6" - } - }, - "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.4", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz", - "integrity": "sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw==", - "requires": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - } - }, - "nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==" - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, - "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==" - }, - "no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "requires": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "optional": true - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" - }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=" - }, - "node-releases": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.3.tgz", - "integrity": "sha512-maHFz6OLqYxz+VQyCAtA3PTX4UP/53pa05fyDNc9CwjvJ0yEh6+xBwKsgCxMNhS8taUKBFYxfuiaD9U/55iFaw==" - }, - "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=" - }, - "normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" - }, - "npm-conf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", - "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", - "dev": true, - "optional": true, - "requires": { - "config-chain": "^1.1.11", - "pify": "^3.0.0" - } - }, - "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==", - "requires": { - "path-key": "^3.0.0" - } - }, - "nth-check": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", - "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", - "requires": { - "boolbase": "^1.0.0" - } - }, - "nwsapi": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", - "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" - }, - "object-inspect": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", - "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==" - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" - }, - "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", - "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "object.fromentries": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz", - "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "object.getownpropertydescriptors": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", - "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "object.hasown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz", - "integrity": "sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" - }, - "on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "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==" - }, - "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==", - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "open": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", - "requires": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "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" - } - }, - "p-cancelable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", - "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==", - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "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==", - "requires": { - "p-limit": "^3.0.2" - } - }, - "p-retry": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.1.tgz", - "integrity": "sha512-e2xXGNhZOZ0lfgR9kL34iGlU8N/KO0xZnQxVEwdeOvpqNDQfdnxIYizvWtK8RglUa3bGqI8g0R/BdfzLMxRkiA==", - "requires": { - "@types/retry": "^0.12.0", - "retry": "^0.13.1" - } - }, - "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==" - }, - "package-json": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", - "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", - "dev": true, - "requires": { - "got": "^9.6.0", - "registry-auth-token": "^4.0.0", - "registry-url": "^5.0.0", - "semver": "^6.2.0" - } - }, - "param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "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==", - "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==", - "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" - } - }, - "parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "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==" - }, - "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=" - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "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==" - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", - "dev": true - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "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==" - }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true, - "optional": true - }, - "pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==" - }, - "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==", - "requires": { - "find-up": "^4.0.0" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.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==", - "requires": { - "p-limit": "^2.2.0" - } - } - } - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "requires": { - "find-up": "^3.0.0" - }, - "dependencies": { - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "requires": { - "locate-path": "^3.0.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" - } - } - }, - "plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-83vX4eYdQp3vP9SxuYgEM/G/pJQqLUz/V/xzPrzruLs7fz7jxGQ1msZ/mg1nwZxUSuOp4sb+/bEIbRrbzZRxDA==", - "dev": true, - "requires": { - "base64-js": "^1.5.1", - "xmlbuilder": "^9.0.7" - }, - "dependencies": { - "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", - "dev": true - } - } - }, - "portfinder": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", - "integrity": "sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==", - "requires": { - "async": "^2.6.2", - "debug": "^3.1.1", - "mkdirp": "^0.5.5" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "postcss": { - "version": "8.4.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz", - "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==", - "requires": { - "nanoid": "^3.3.1", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "postcss-attribute-case-insensitive": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.0.tgz", - "integrity": "sha512-b4g9eagFGq9T5SWX4+USfVyjIb3liPnjhHHRMP7FMB2kFVpYyfEscV0wP3eaXhKlcHKUut8lt5BGoeylWA/dBQ==", - "requires": { - "postcss-selector-parser": "^6.0.2" - } - }, - "postcss-browser-comments": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", - "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", - "requires": {} - }, - "postcss-calc": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", - "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", - "requires": { - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-clamp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", - "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-color-functional-notation": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.2.tgz", - "integrity": "sha512-DXVtwUhIk4f49KK5EGuEdgx4Gnyj6+t2jBSEmxvpIK9QI40tWrpS2Pua8Q7iIZWBrki2QOaeUdEaLPPa91K0RQ==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-color-hex-alpha": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.3.tgz", - "integrity": "sha512-fESawWJCrBV035DcbKRPAVmy21LpoyiXdPTuHUfWJ14ZRjY7Y7PA6P4g8z6LQGYhU1WAxkTxjIjurXzoe68Glw==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-color-rebeccapurple": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.0.2.tgz", - "integrity": "sha512-SFc3MaocHaQ6k3oZaFwH8io6MdypkUtEy/eXzXEB1vEQlO3S3oDc/FSZA8AsS04Z25RirQhlDlHLh3dn7XewWw==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-colormin": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", - "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", - "requires": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0", - "colord": "^2.9.1", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-convert-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.0.tgz", - "integrity": "sha512-GkyPbZEYJiWtQB0KZ0X6qusqFHUepguBCNFi9t5JJc7I2OTXG7C0twbTLvCfaKOLl3rSXmpAwV7W5txd91V84g==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-custom-media": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.0.tgz", - "integrity": "sha512-FvO2GzMUaTN0t1fBULDeIvxr5IvbDXcIatt6pnJghc736nqNgsGao5NT+5+WVLAQiTt6Cb3YUms0jiPaXhL//g==", - "requires": {} - }, - "postcss-custom-properties": { - "version": "12.1.7", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.7.tgz", - "integrity": "sha512-N/hYP5gSoFhaqxi2DPCmvto/ZcRDVjE3T1LiAMzc/bg53hvhcHOLpXOHb526LzBBp5ZlAUhkuot/bfpmpgStJg==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-custom-selectors": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.0.tgz", - "integrity": "sha512-/1iyBhz/W8jUepjGyu7V1OPcGbc636snN1yXEQCinb6Bwt7KxsiU7/bLQlp8GwAXzCh7cobBU5odNn/2zQWR8Q==", - "requires": { - "postcss-selector-parser": "^6.0.4" - } - }, - "postcss-dir-pseudo-class": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.4.tgz", - "integrity": "sha512-I8epwGy5ftdzNWEYok9VjW9whC4xnelAtbajGv4adql4FIF09rnrxnA9Y8xSHN47y7gqFIv10C5+ImsLeJpKBw==", - "requires": { - "postcss-selector-parser": "^6.0.9" - } - }, - "postcss-discard-comments": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz", - "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==", - "requires": {} - }, - "postcss-discard-duplicates": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", - "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", - "requires": {} - }, - "postcss-discard-empty": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", - "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", - "requires": {} - }, - "postcss-discard-overridden": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", - "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", - "requires": {} - }, - "postcss-double-position-gradients": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.1.tgz", - "integrity": "sha512-jM+CGkTs4FcG53sMPjrrGE0rIvLDdCrqMzgDC5fLI7JHDO7o6QG8C5TQBtExb13hdBdoH9C2QVbG4jo2y9lErQ==", - "requires": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-env-function": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", - "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-flexbugs-fixes": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", - "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", - "requires": {} - }, - "postcss-focus-visible": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", - "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", - "requires": { - "postcss-selector-parser": "^6.0.9" - } - }, - "postcss-focus-within": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", - "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", - "requires": { - "postcss-selector-parser": "^6.0.9" - } - }, - "postcss-font-variant": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", - "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "requires": {} - }, - "postcss-gap-properties": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.3.tgz", - "integrity": "sha512-rPPZRLPmEKgLk/KlXMqRaNkYTUpE7YC+bOIQFN5xcu1Vp11Y4faIXv6/Jpft6FMnl6YRxZqDZG0qQOW80stzxQ==", - "requires": {} - }, - "postcss-image-set-function": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.6.tgz", - "integrity": "sha512-KfdC6vg53GC+vPd2+HYzsZ6obmPqOk6HY09kttU19+Gj1nC3S3XBVEXDHxkhxTohgZqzbUb94bKXvKDnYWBm/A==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-initial": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", - "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "requires": {} - }, - "postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "requires": { - "camelcase-css": "^2.0.1" - } - }, - "postcss-lab-function": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.0.tgz", - "integrity": "sha512-Zb1EO9DGYfa3CP8LhINHCcTTCTLI+R3t7AX2mKsDzdgVQ/GkCpHOTgOr6HBHslP7XDdVbqgHW5vvRPMdVANQ8w==", - "requires": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "requires": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - } - }, - "postcss-loader": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", - "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", - "requires": { - "cosmiconfig": "^7.0.0", - "klona": "^2.0.5", - "semver": "^7.3.5" - }, - "dependencies": { - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "postcss-logical": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", - "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", - "requires": {} - }, - "postcss-media-minmax": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", - "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", - "requires": {} - }, - "postcss-merge-longhand": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.4.tgz", - "integrity": "sha512-hbqRRqYfmXoGpzYKeW0/NCZhvNyQIlQeWVSao5iKWdyx7skLvCfQFGIUsP9NUs3dSbPac2IC4Go85/zG+7MlmA==", - "requires": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^5.1.0" - } - }, - "postcss-merge-rules": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz", - "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==", - "requires": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^3.1.0", - "postcss-selector-parser": "^6.0.5" - } - }, - "postcss-minify-font-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", - "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-minify-gradients": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", - "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", - "requires": { - "colord": "^2.9.1", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-minify-params": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.2.tgz", - "integrity": "sha512-aEP+p71S/urY48HWaRHasyx4WHQJyOYaKpQ6eXl8k0kxg66Wt/30VR6/woh8THgcpRbonJD5IeD+CzNhPi1L8g==", - "requires": { - "browserslist": "^4.16.6", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-minify-selectors": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz", - "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==", - "requires": { - "postcss-selector-parser": "^6.0.5" - } - }, - "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==", - "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==", - "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==", - "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==", - "requires": { - "icss-utils": "^5.0.0" - } - }, - "postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "requires": { - "postcss-selector-parser": "^6.0.6" - } - }, - "postcss-nesting": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.1.4.tgz", - "integrity": "sha512-2ixdQ59ik/Gt1+oPHiI1kHdwEI8lLKEmui9B1nl6163ANLC+GewQn7fXMxJF2JSb4i2MKL96GU8fIiQztK4TTA==", - "requires": { - "postcss-selector-parser": "^6.0.10" - } - }, - "postcss-normalize": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", - "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", - "requires": { - "@csstools/normalize.css": "*", - "postcss-browser-comments": "^4", - "sanitize.css": "*" - } - }, - "postcss-normalize-charset": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", - "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", - "requires": {} - }, - "postcss-normalize-display-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", - "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-positions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.0.tgz", - "integrity": "sha512-8gmItgA4H5xiUxgN/3TVvXRoJxkAWLW6f/KKhdsH03atg0cB8ilXnrB5PpSshwVu/dD2ZsRFQcR1OEmSBDAgcQ==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-repeat-style": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.0.tgz", - "integrity": "sha512-IR3uBjc+7mcWGL6CtniKNQ4Rr5fTxwkaDHwMBDGGs1x9IVRkYIT/M4NelZWkAOBdV6v3Z9S46zqaKGlyzHSchw==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-string": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", - "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-timing-functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", - "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-unicode": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", - "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", - "requires": { - "browserslist": "^4.16.6", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-url": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", - "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", - "requires": { - "normalize-url": "^6.0.1", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-normalize-whitespace": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", - "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-opacity-percentage": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.2.tgz", - "integrity": "sha512-lyUfF7miG+yewZ8EAk9XUBIlrHyUE6fijnesuz+Mj5zrIHIEw6KcIZSOk/elVMqzLvREmXB83Zi/5QpNRYd47w==" - }, - "postcss-ordered-values": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.1.tgz", - "integrity": "sha512-7lxgXF0NaoMIgyihL/2boNAEZKiW0+HkMhdKMTD93CjW8TdCy2hSdj8lsAo+uwm7EDG16Da2Jdmtqpedl0cMfw==", - "requires": { - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-overflow-shorthand": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.3.tgz", - "integrity": "sha512-CxZwoWup9KXzQeeIxtgOciQ00tDtnylYIlJBBODqkgS/PU2jISuWOL/mYLHmZb9ZhZiCaNKsCRiLp22dZUtNsg==", - "requires": {} - }, - "postcss-page-break": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", - "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "requires": {} - }, - "postcss-place": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.4.tgz", - "integrity": "sha512-MrgKeiiu5OC/TETQO45kV3npRjOFxEHthsqGtkh3I1rPbZSbXGD/lZVi9j13cYh+NA8PIAPyk6sGjT9QbRyvSg==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-preset-env": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.4.3.tgz", - "integrity": "sha512-dlPA65g9KuGv7YsmGyCKtFkZKCPLkoVMUE3omOl6yM+qrynVHxFvf0tMuippIrXB/sB/MyhL1FgTIbrO+qMERg==", - "requires": { - "@csstools/postcss-color-function": "^1.0.3", - "@csstools/postcss-font-format-keywords": "^1.0.0", - "@csstools/postcss-hwb-function": "^1.0.0", - "@csstools/postcss-ic-unit": "^1.0.0", - "@csstools/postcss-is-pseudo-class": "^2.0.1", - "@csstools/postcss-normalize-display-values": "^1.0.0", - "@csstools/postcss-oklab-function": "^1.0.2", - "@csstools/postcss-progressive-custom-properties": "^1.3.0", - "autoprefixer": "^10.4.4", - "browserslist": "^4.20.2", - "css-blank-pseudo": "^3.0.3", - "css-has-pseudo": "^3.0.4", - "css-prefers-color-scheme": "^6.0.3", - "cssdb": "^6.5.0", - "postcss-attribute-case-insensitive": "^5.0.0", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^4.2.2", - "postcss-color-hex-alpha": "^8.0.3", - "postcss-color-rebeccapurple": "^7.0.2", - "postcss-custom-media": "^8.0.0", - "postcss-custom-properties": "^12.1.5", - "postcss-custom-selectors": "^6.0.0", - "postcss-dir-pseudo-class": "^6.0.4", - "postcss-double-position-gradients": "^3.1.1", - "postcss-env-function": "^4.0.6", - "postcss-focus-visible": "^6.0.4", - "postcss-focus-within": "^5.0.4", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^3.0.3", - "postcss-image-set-function": "^4.0.6", - "postcss-initial": "^4.0.1", - "postcss-lab-function": "^4.1.2", - "postcss-logical": "^5.0.4", - "postcss-media-minmax": "^5.0.0", - "postcss-nesting": "^10.1.3", - "postcss-opacity-percentage": "^1.1.2", - "postcss-overflow-shorthand": "^3.0.3", - "postcss-page-break": "^3.0.4", - "postcss-place": "^7.0.4", - "postcss-pseudo-class-any-link": "^7.1.1", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^5.0.0", - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-pseudo-class-any-link": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.2.tgz", - "integrity": "sha512-76XzEQv3g+Vgnz3tmqh3pqQyRojkcJ+pjaePsyhcyf164p9aZsu3t+NWxkZYbcHLK1ju5Qmalti2jPI5IWCe5w==", - "requires": { - "postcss-selector-parser": "^6.0.10" - } - }, - "postcss-reduce-initial": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", - "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", - "requires": { - "browserslist": "^4.16.6", - "caniuse-api": "^3.0.0" - } - }, - "postcss-reduce-transforms": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", - "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", - "requires": { - "postcss-value-parser": "^4.2.0" - } - }, - "postcss-replace-overflow-wrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", - "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "requires": {} - }, - "postcss-selector-not": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-5.0.0.tgz", - "integrity": "sha512-/2K3A4TCP9orP4TNS7u3tGdRFVKqz/E6pX3aGnriPG0jU78of8wsUcqE4QAhWEU0d+WnMSF93Ah3F//vUtK+iQ==", - "requires": { - "balanced-match": "^1.0.0" - } - }, - "postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "requires": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - } - }, - "postcss-svgo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", - "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", - "requires": { - "postcss-value-parser": "^4.2.0", - "svgo": "^2.7.0" - }, - "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==" - }, - "css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", - "requires": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - } - }, - "mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", - "requires": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", - "css-select": "^4.1.3", - "css-tree": "^1.1.3", - "csso": "^4.2.0", - "picocolors": "^1.0.0", - "stable": "^0.1.8" - } - } - } - }, - "postcss-unique-selectors": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", - "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", - "requires": { - "postcss-selector-parser": "^6.0.5" - } - }, - "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==" - }, - "prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", - "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==" - }, - "pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "requires": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "requires": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" - } - } - }, - "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==" - }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "promise": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", - "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", - "requires": { - "asap": "~2.0.6" - } - }, - "prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "requires": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - } - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - } - } - }, - "proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", - "dev": true, - "optional": true - }, - "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==", - "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==" - } - } - }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "pupa": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", - "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", - "dev": true, - "requires": { - "escape-goat": "^2.0.0" - } - }, - "q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" - }, - "qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==" - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" - }, - "raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "requires": { - "performance-now": "^2.1.0" - } - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "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==" - }, - "raw-body": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz", - "integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==", - "requires": { - "bytes": "3.1.2", - "http-errors": "1.8.1", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "dependencies": { - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - } - } - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true - } - } - }, - "react": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.0.0.tgz", - "integrity": "sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==", - "requires": { - "loose-envify": "^1.1.0" - } - }, - "react-app-polyfill": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", - "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", - "requires": { - "core-js": "^3.19.2", - "object-assign": "^4.1.1", - "promise": "^8.1.0", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.9", - "whatwg-fetch": "^3.6.2" - } - }, - "react-dev-utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", - "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", - "requires": { - "@babel/code-frame": "^7.16.0", - "address": "^1.1.2", - "browserslist": "^4.18.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "detect-port-alt": "^1.1.6", - "escape-string-regexp": "^4.0.0", - "filesize": "^8.0.6", - "find-up": "^5.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.0", - "global-modules": "^2.0.0", - "globby": "^11.0.4", - "gzip-size": "^6.0.0", - "immer": "^9.0.7", - "is-root": "^2.1.0", - "loader-utils": "^3.2.0", - "open": "^8.4.0", - "pkg-up": "^3.1.0", - "prompts": "^2.4.2", - "react-error-overlay": "^6.0.11", - "recursive-readdir": "^2.2.2", - "shell-quote": "^1.7.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.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" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "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==", - "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==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "loader-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.0.tgz", - "integrity": "sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==" - }, - "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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "react-dom": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.0.0.tgz", - "integrity": "sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==", - "requires": { - "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" - } - }, - "react-error-overlay": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", - "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" - }, - "react-i18next": { - "version": "11.16.7", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz", - "integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==", - "requires": { - "@babel/runtime": "^7.14.5", - "html-escaper": "^2.0.2", - "html-parse-stringify": "^3.0.1" - } - }, - "react-infinite-scroll-component": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", - "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", - "requires": { - "throttle-debounce": "^2.1.0" - } - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "react-refresh": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", - "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" - }, - "react-router": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", - "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", - "requires": { - "history": "^5.2.0" - } - }, - "react-router-dom": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", - "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", - "requires": { - "history": "^5.2.0", - "react-router": "6.3.0" - } - }, - "react-scripts": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", - "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", - "requires": { - "@babel/core": "^7.16.0", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", - "@svgr/webpack": "^5.5.0", - "babel-jest": "^27.4.2", - "babel-loader": "^8.2.3", - "babel-plugin-named-asset-import": "^0.3.8", - "babel-preset-react-app": "^10.0.1", - "bfj": "^7.0.2", - "browserslist": "^4.18.1", - "camelcase": "^6.2.1", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "css-loader": "^6.5.1", - "css-minimizer-webpack-plugin": "^3.2.0", - "dotenv": "^10.0.0", - "dotenv-expand": "^5.1.0", - "eslint": "^8.3.0", - "eslint-config-react-app": "^7.0.1", - "eslint-webpack-plugin": "^3.1.1", - "file-loader": "^6.2.0", - "fs-extra": "^10.0.0", - "fsevents": "^2.3.2", - "html-webpack-plugin": "^5.5.0", - "identity-obj-proxy": "^3.0.0", - "jest": "^27.4.3", - "jest-resolve": "^27.4.2", - "jest-watch-typeahead": "^1.0.0", - "mini-css-extract-plugin": "^2.4.5", - "postcss": "^8.4.4", - "postcss-flexbugs-fixes": "^5.0.2", - "postcss-loader": "^6.2.1", - "postcss-normalize": "^10.0.1", - "postcss-preset-env": "^7.0.1", - "prompts": "^2.4.2", - "react-app-polyfill": "^3.0.0", - "react-dev-utils": "^12.0.1", - "react-refresh": "^0.11.0", - "resolve": "^1.20.0", - "resolve-url-loader": "^4.0.0", - "sass-loader": "^12.3.0", - "semver": "^7.3.5", - "source-map-loader": "^3.0.0", - "style-loader": "^3.3.1", - "tailwindcss": "^3.0.2", - "terser-webpack-plugin": "^5.2.5", - "webpack": "^5.64.4", - "webpack-dev-server": "^4.6.0", - "webpack-manifest-plugin": "^4.0.2", - "workbox-webpack-plugin": "^6.4.1" - }, - "dependencies": { - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "read-config-file": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.2.0.tgz", - "integrity": "sha512-gx7Pgr5I56JtYz+WuqEbQHj/xWo+5Vwua2jhb1VwM4Wid5PqYmZ4i00ZB0YEGIfkVBsCv9UrjgyqCiQfS/Oosg==", - "dev": true, - "requires": { - "dotenv": "^9.0.2", - "dotenv-expand": "^5.1.0", - "js-yaml": "^4.1.0", - "json5": "^2.2.0", - "lazy-val": "^1.0.4" - }, - "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 - }, - "dotenv": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", - "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", - "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" - } - } - } - }, - "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==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.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" - } - }, - "recursive-readdir": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", - "integrity": "sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==", - "requires": { - "minimatch": "3.0.4" - }, - "dependencies": { - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "requires": { - "brace-expansion": "^1.1.7" - } - } - } - }, - "regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" - }, - "regenerate-unicode-properties": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz", - "integrity": "sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==", - "requires": { - "regenerate": "^1.4.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "regenerator-transform": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", - "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", - "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==" - }, - "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - } - }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==" - }, - "regexpu-core": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz", - "integrity": "sha512-CriEZlrKK9VJw/xQGJpQM5rY88BtuL8DM+AEwvcThHilbxiTAy8vq4iJnd2tqq8wLmjbGZzP7ZcKFjbGkmEFrw==", - "requires": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.0.1", - "regjsgen": "^0.6.0", - "regjsparser": "^0.8.2", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.0.0" - } - }, - "registry-auth-token": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", - "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", - "dev": true, - "requires": { - "rc": "^1.2.8" - } - }, - "registry-url": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", - "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", - "dev": true, - "requires": { - "rc": "^1.2.8" - } - }, - "regjsgen": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.6.0.tgz", - "integrity": "sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==" - }, - "regjsparser": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.8.4.tgz", - "integrity": "sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==", - "requires": { - "jsesc": "~0.5.0" - }, - "dependencies": { - "jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" - } - } - }, - "relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" - }, - "renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "requires": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "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==" - }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" - }, - "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "requires": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "requires": { - "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==" - } - } - }, - "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==" - }, - "resolve-url-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", - "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", - "requires": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^7.0.35", - "source-map": "0.6.1" - }, - "dependencies": { - "picocolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" - }, - "postcss": { - "version": "7.0.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", - "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", - "requires": { - "picocolors": "^0.2.1", - "source-map": "^0.6.1" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "resolve.exports": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==" - }, - "responselike": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", - "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", - "dev": true, - "requires": { - "lowercase-keys": "^1.0.0" - } - }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - }, - "roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "requires": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "dependencies": { - "sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true, - "optional": true - } - } - }, - "rollup": { - "version": "2.70.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.2.tgz", - "integrity": "sha512-EitogNZnfku65I1DD5Mxe8JYRUCy0hkK5X84IlDtUs+O6JRMpRciXTzyCUuX11b5L5pvjH+OmFXiQ3XjabcXgg==", - "requires": { - "fsevents": "~2.3.2" - } - }, - "rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "requires": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.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==" - }, - "jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - } - }, - "serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "requires": { - "randombytes": "^2.1.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==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "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==", - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "dev": true, - "requires": { - "tslib": "^1.9.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 - } - } - }, - "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==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "dev": true, - "requires": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "sanitize.css": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", - "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" - }, - "sass-loader": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", - "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", - "requires": { - "klona": "^2.0.4", - "neo-async": "^2.6.2" - } - }, - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, - "saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "requires": { - "xmlchars": "^2.2.0" - } - }, - "scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", - "requires": { - "loose-envify": "^1.1.0" - } - }, - "schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - }, - "select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" - }, - "selfsigned": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz", - "integrity": "sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==", - "requires": { - "node-forge": "^1" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" - }, - "semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", - "dev": true, - "optional": true - }, - "semver-diff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", - "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", - "dev": true, - "requires": { - "semver": "^6.3.0" - } - }, - "send": { - "version": "0.17.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", - "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", - "requires": { - "debug": "2.6.9", - "depd": "~1.1.2", - "destroy": "~1.0.4", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "1.8.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.3.0", - "range-parser": "~1.2.1", - "statuses": "~1.5.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "requires": { - "type-fest": "^0.13.1" - }, - "dependencies": { - "type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true - } - } - }, - "serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "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": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", - "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==", - "requires": { - "ms": "2.0.0" - } - }, - "http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", - "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": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - } - } - }, - "serve-static": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz", - "integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==", - "requires": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.17.2" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "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==", - "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==" - }, - "shell-quote": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", - "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==" - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "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==" - }, - "sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" - }, - "slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.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, - "optional": 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, - "optional": 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, - "optional": 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, - "optional": true - }, - "sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "requires": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "source-list-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" - }, - "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": "3.0.1", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.1.tgz", - "integrity": "sha512-Vp1UsfyPvgujKQzi4pyDiTOnE3E4H+yHvkVRN3c/9PJmQS4CQJExvcDvaX/D+RV+xQben9HJ56jMJS3CgUeWyA==", - "requires": { - "abab": "^2.0.5", - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.1" - } - }, - "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==", - "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==" - } - } - }, - "sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" - }, - "spawn-command": { - "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", - "dev": true - }, - "spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "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==", - "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" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" - }, - "stable": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" - }, - "stack-generator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz", - "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==", - "requires": { - "stackframe": "^1.1.1" - } - }, - "stack-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", - "requires": { - "escape-string-regexp": "^2.0.0" - }, - "dependencies": { - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" - } - } - }, - "stackframe": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.1.tgz", - "integrity": "sha512-h88QkzREN/hy8eRdyNhhsO7RSJ5oyTqxxmmn0dzBIMUclZsjpfmrsg81vp8mjjAs2vAZ72nyWxRUwSwmh0e4xg==" - }, - "stacktrace-gps": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz", - "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==", - "requires": { - "source-map": "0.5.6", - "stackframe": "^1.1.1" - }, - "dependencies": { - "source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=" - } - } - }, - "stacktrace-js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", - "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", - "requires": { - "error-stack-parser": "^2.0.6", - "stack-generator": "^2.0.5", - "stacktrace-gps": "^3.0.4" - } - }, - "stat-mode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", - "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", - "dev": true - }, - "statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" - }, - "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==", - "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==" - } - } - }, - "string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "requires": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - } - }, - "string-natural-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", - "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" - }, - "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" - }, - "dependencies": { - "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==" - } - } - }, - "string.prototype.matchall": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", - "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1", - "get-intrinsic": "^1.1.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.1", - "side-channel": "^1.0.4" - } - }, - "string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "string.prototype.trimstart": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "requires": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - } - }, - "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-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==" - }, - "strip-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", - "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==" - }, - "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==" - }, - "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==" - }, - "style-loader": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", - "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", - "requires": {} - }, - "stylehacks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", - "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", - "requires": { - "browserslist": "^4.16.6", - "postcss-selector-parser": "^6.0.4" - } - }, - "stylis": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", - "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" - }, - "sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, - "requires": { - "debug": "^4.1.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-hyperlinks": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz", - "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==", - "requires": { - "has-flag": "^4.0.0", - "supports-color": "^7.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==" - }, - "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==", - "requires": { - "has-flag": "^4.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==" - }, - "svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" - }, - "svgo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", - "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", - "requires": { - "chalk": "^2.4.1", - "coa": "^2.0.2", - "css-select": "^2.0.0", - "css-select-base-adapter": "^0.1.1", - "css-tree": "1.0.0-alpha.37", - "csso": "^4.0.2", - "js-yaml": "^3.13.1", - "mkdirp": "~0.5.1", - "object.values": "^1.1.0", - "sax": "~1.2.4", - "stable": "^0.1.8", - "unquote": "~1.1.1", - "util.promisify": "~1.0.0" - }, - "dependencies": { - "css-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", - "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", - "requires": { - "boolbase": "^1.0.0", - "css-what": "^3.2.1", - "domutils": "^1.7.0", - "nth-check": "^1.0.2" - } - }, - "css-what": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", - "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==" - }, - "dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "requires": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - } - }, - "domutils": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", - "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - }, - "dependencies": { - "domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" - } - } - }, - "nth-check": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", - "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", - "requires": { - "boolbase": "~1.0.0" - } - } - } - }, - "symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" - }, - "tailwindcss": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.24.tgz", - "integrity": "sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==", - "requires": { - "arg": "^5.0.1", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "lilconfig": "^2.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.12", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.4", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.10", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "dependencies": { - "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==" - } - } - }, - "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" - }, - "temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==" - }, - "temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", - "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", - "dev": true, - "requires": { - "async-exit-hook": "^2.0.1", - "fs-extra": "^10.0.0" - } - }, - "tempy": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", - "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", - "requires": { - "is-stream": "^2.0.0", - "temp-dir": "^2.0.0", - "type-fest": "^0.16.0", - "unique-string": "^2.0.0" - }, - "dependencies": { - "type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==" - } - } - }, - "terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "requires": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - } - }, - "terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", - "requires": { - "acorn": "^8.5.0", - "commander": "^2.20.0", - "source-map": "~0.7.2", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "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==" - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" - } - } - }, - "terser-webpack-plugin": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz", - "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==", - "requires": { - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.0", - "source-map": "^0.6.1", - "terser": "^5.7.2" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, - "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==", - "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=" - }, - "throat": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", - "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" - }, - "throttle-debounce": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", - "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==" - }, - "thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, - "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" - } - }, - "tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "requires": { - "tmp": "^0.2.0" - } - }, - "tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" - }, - "to-readable-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", - "dev": true - }, - "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==" - }, - "tough-cookie": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", - "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", - "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.1.2" - }, - "dependencies": { - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" - } - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "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 - }, - "truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", - "dev": true, - "requires": { - "utf8-byte-length": "^1.0.1" - } - }, - "tryer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", - "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" - }, - "tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", - "requires": { - "@types/json5": "^0.0.29", - "json5": "^1.0.1", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "dependencies": { - "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", - "requires": { - "minimist": "^1.2.0" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=" - } - } - }, - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "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==" - } - } - }, - "tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "optional": 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==", - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" - }, - "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==" - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", - "peer": true - }, - "unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", - "requires": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", - "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==" - }, - "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==", - "requires": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - } - }, - "unicode-match-property-value-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", - "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==" - }, - "unicode-property-aliases-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", - "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==" - }, - "unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "requires": { - "crypto-random-string": "^2.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" - }, - "unquote": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=" - }, - "upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==" - }, - "update-notifier": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz", - "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==", - "dev": true, - "requires": { - "boxen": "^5.0.0", - "chalk": "^4.1.0", - "configstore": "^5.0.1", - "has-yarn": "^2.1.0", - "import-lazy": "^2.1.0", - "is-ci": "^2.0.0", - "is-installed-globally": "^0.4.0", - "is-npm": "^5.0.0", - "is-yarn-global": "^0.3.0", - "latest-version": "^5.1.0", - "pupa": "^2.1.1", - "semver": "^7.3.4", - "semver-diff": "^3.1.1", - "xdg-basedir": "^4.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" - } - }, - "ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true - }, - "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 - }, - "is-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", - "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", - "dev": true, - "requires": { - "ci-info": "^2.0.0" - } - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "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" - } - } - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, - "url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", - "dev": true, - "requires": { - "prepend-http": "^2.0.0" - } - }, - "utf8-byte-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=", - "dev": true - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "util.promisify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", - "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.2", - "has-symbols": "^1.0.1", - "object.getownpropertydescriptors": "^2.1.0" - } - }, - "utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=" - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, - "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==" - }, - "v8-to-istanbul": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", - "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", - "requires": { - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0", - "source-map": "^0.7.3" - }, - "dependencies": { - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" - } - } - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" - }, - "verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "optional": true, - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "dependencies": { - "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, - "optional": true - } - } - }, - "void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=" - }, - "w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "requires": { - "browser-process-hrtime": "^1.0.0" - } - }, - "w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", - "requires": { - "xml-name-validator": "^3.0.0" - } - }, - "wait-on": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", - "integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", - "dev": true, - "requires": { - "axios": "^0.25.0", - "joi": "^17.6.0", - "lodash": "^4.17.21", - "minimist": "^1.2.5", - "rxjs": "^7.5.4" - }, - "dependencies": { - "rxjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", - "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "requires": { - "makeerror": "1.0.12" - } - }, - "watchpack": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", - "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", - "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==", - "requires": { - "minimalistic-assert": "^1.0.0" - } - }, - "webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==" - }, - "webpack": { - "version": "5.72.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.0.tgz", - "integrity": "sha512-qmSmbspI0Qo5ld49htys8GY9XhS9CGqFoHTsOVAnjBdg0Zn79y135R+k4IR4rKK6+eKaabMhJwiVB7xw0SJu5w==", - "requires": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.4.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.9.2", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-better-errors": "^1.0.2", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.3.1", - "webpack-sources": "^3.2.3" - }, - "dependencies": { - "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==", - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" - } - } - }, - "webpack-dev-middleware": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.1.tgz", - "integrity": "sha512-81EujCKkyles2wphtdrnPg/QqegC/AtqNH//mQkBYSMqwFVCQrxM6ktB2O/SPlZy7LqeEfTbV3cZARGQz6umhg==", - "requires": { - "colorette": "^2.0.10", - "memfs": "^3.4.1", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "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==" - }, - "schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - } - } - } - }, - "webpack-dev-server": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.8.1.tgz", - "integrity": "sha512-dwld70gkgNJa33czmcj/PlKY/nOy/BimbrgZRaR9vDATBQAYgLzggR0nxDtPLJiLrMgZwbE6RRfJ5vnBBasTyg==", - "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/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": "^1.6.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", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "portfinder": "^1.0.28", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.0.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.21", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.4.2" - }, - "dependencies": { - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "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-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "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==" - }, - "schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" - } - }, - "ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "requires": {} - } - } - }, - "webpack-manifest-plugin": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", - "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", - "requires": { - "tapable": "^2.0.0", - "webpack-sources": "^2.2.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==" - }, - "webpack-sources": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", - "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", - "requires": { - "source-list-map": "^2.0.1", - "source-map": "^0.6.1" - } - } - } - }, - "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==" - }, - "websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "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==" - }, - "whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "requires": { - "iconv-lite": "0.4.24" - }, - "dependencies": { - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - } - } - }, - "whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" - }, - "whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - }, - "dependencies": { - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - } - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "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==", - "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" - } - }, - "widest-line": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", - "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", - "dev": true, - "requires": { - "string-width": "^4.0.0" - } - }, - "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==" - }, - "workbox-background-sync": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.3.tgz", - "integrity": "sha512-0DD/V05FAcek6tWv9XYj2w5T/plxhDSpclIcAGjA/b7t/6PdaRkQ7ZgtAX6Q/L7kV7wZ8uYRJUoH11VjNipMZw==", - "requires": { - "idb": "^6.1.4", - "workbox-core": "6.5.3" - } - }, - "workbox-broadcast-update": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.3.tgz", - "integrity": "sha512-4AwCIA5DiDrYhlN+Miv/fp5T3/whNmSL+KqhTwRBTZIL6pvTgE4lVuRzAt1JltmqyMcQ3SEfCdfxczuI4kwFQg==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-build": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.3.tgz", - "integrity": "sha512-8JNHHS7u13nhwIYCDea9MNXBNPHXCs5KDZPKI/ZNTr3f4sMGoD7hgFGecbyjX1gw4z6e9bMpMsOEJNyH5htA/w==", - "requires": { - "@apideck/better-ajv-errors": "^0.3.1", - "@babel/core": "^7.11.1", - "@babel/preset-env": "^7.11.0", - "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^11.2.1", - "@rollup/plugin-replace": "^2.4.1", - "@surma/rollup-plugin-off-main-thread": "^2.2.3", - "ajv": "^8.6.0", - "common-tags": "^1.8.0", - "fast-json-stable-stringify": "^2.1.0", - "fs-extra": "^9.0.1", - "glob": "^7.1.6", - "lodash": "^4.17.20", - "pretty-bytes": "^5.3.0", - "rollup": "^2.43.1", - "rollup-plugin-terser": "^7.0.0", - "source-map": "^0.8.0-beta.0", - "stringify-object": "^3.3.0", - "strip-comments": "^2.0.1", - "tempy": "^0.6.0", - "upath": "^1.2.0", - "workbox-background-sync": "6.5.3", - "workbox-broadcast-update": "6.5.3", - "workbox-cacheable-response": "6.5.3", - "workbox-core": "6.5.3", - "workbox-expiration": "6.5.3", - "workbox-google-analytics": "6.5.3", - "workbox-navigation-preload": "6.5.3", - "workbox-precaching": "6.5.3", - "workbox-range-requests": "6.5.3", - "workbox-recipes": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3", - "workbox-streams": "6.5.3", - "workbox-sw": "6.5.3", - "workbox-window": "6.5.3" - }, - "dependencies": { - "@apideck/better-ajv-errors": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.3.tgz", - "integrity": "sha512-9o+HO2MbJhJHjDYZaDxJmSDckvDpiuItEsrIShV0DXeCshXWRHhqYyU/PKHMkuClOmFnZhRd6wzv4vpDu/dRKg==", - "requires": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", - "leven": "^3.1.0" - } - }, - "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "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==" - }, - "source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "requires": { - "whatwg-url": "^7.0.0" - } - }, - "tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "requires": { - "punycode": "^2.1.0" - } - }, - "webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - } - } - }, - "workbox-cacheable-response": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.3.tgz", - "integrity": "sha512-6JE/Zm05hNasHzzAGKDkqqgYtZZL2H06ic2GxuRLStA4S/rHUfm2mnLFFXuHAaGR1XuuYyVCEey1M6H3PdZ7SQ==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-core": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.3.tgz", - "integrity": "sha512-Bb9ey5n/M9x+l3fBTlLpHt9ASTzgSGj6vxni7pY72ilB/Pb3XtN+cZ9yueboVhD5+9cNQrC9n/E1fSrqWsUz7Q==" - }, - "workbox-expiration": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.3.tgz", - "integrity": "sha512-jzYopYR1zD04ZMdlbn/R2Ik6ixiXbi15c9iX5H8CTi6RPDz7uhvMLZPKEndZTpfgmUk8mdmT9Vx/AhbuCl5Sqw==", - "requires": { - "idb": "^6.1.4", - "workbox-core": "6.5.3" - } - }, - "workbox-google-analytics": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.3.tgz", - "integrity": "sha512-3GLCHotz5umoRSb4aNQeTbILETcrTVEozSfLhHSBaegHs1PnqCmN0zbIy2TjTpph2AGXiNwDrWGF0AN+UgDNTw==", - "requires": { - "workbox-background-sync": "6.5.3", - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" - } - }, - "workbox-navigation-preload": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.3.tgz", - "integrity": "sha512-bK1gDFTc5iu6lH3UQ07QVo+0ovErhRNGvJJO/1ngknT0UQ702nmOUhoN9qE5mhuQSrnK+cqu7O7xeaJ+Rd9Tmg==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-precaching": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.3.tgz", - "integrity": "sha512-sjNfgNLSsRX5zcc63H/ar/hCf+T19fRtTqvWh795gdpghWb5xsfEkecXEvZ8biEi1QD7X/ljtHphdaPvXDygMQ==", - "requires": { - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" - } - }, - "workbox-range-requests": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.3.tgz", - "integrity": "sha512-pGCP80Bpn/0Q0MQsfETSfmtXsQcu3M2QCJwSFuJ6cDp8s2XmbUXkzbuQhCUzKR86ZH2Vex/VUjb2UaZBGamijA==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-recipes": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.3.tgz", - "integrity": "sha512-IcgiKYmbGiDvvf3PMSEtmwqxwfQ5zwI7OZPio3GWu4PfehA8jI8JHI3KZj+PCfRiUPZhjQHJ3v1HbNs+SiSkig==", - "requires": { - "workbox-cacheable-response": "6.5.3", - "workbox-core": "6.5.3", - "workbox-expiration": "6.5.3", - "workbox-precaching": "6.5.3", - "workbox-routing": "6.5.3", - "workbox-strategies": "6.5.3" - } - }, - "workbox-routing": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.3.tgz", - "integrity": "sha512-DFjxcuRAJjjt4T34RbMm3MCn+xnd36UT/2RfPRfa8VWJGItGJIn7tG+GwVTdHmvE54i/QmVTJepyAGWtoLPTmg==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-strategies": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.3.tgz", - "integrity": "sha512-MgmGRrDVXs7rtSCcetZgkSZyMpRGw8HqL2aguszOc3nUmzGZsT238z/NN9ZouCxSzDu3PQ3ZSKmovAacaIhu1w==", - "requires": { - "workbox-core": "6.5.3" - } - }, - "workbox-streams": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.3.tgz", - "integrity": "sha512-vN4Qi8o+b7zj1FDVNZ+PlmAcy1sBoV7SC956uhqYvZ9Sg1fViSbOpydULOssVJ4tOyKRifH/eoi6h99d+sJ33w==", - "requires": { - "workbox-core": "6.5.3", - "workbox-routing": "6.5.3" - } - }, - "workbox-sw": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.3.tgz", - "integrity": "sha512-BQBzm092w+NqdIEF2yhl32dERt9j9MDGUTa2Eaa+o3YKL4Qqw55W9yQC6f44FdAHdAJrJvp0t+HVrfh8AiGj8A==" - }, - "workbox-webpack-plugin": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.3.tgz", - "integrity": "sha512-Es8Xr02Gi6Kc3zaUwR691ZLy61hz3vhhs5GztcklQ7kl5k2qAusPh0s6LF3wEtlpfs9ZDErnmy5SErwoll7jBA==", - "requires": { - "fast-json-stable-stringify": "^2.1.0", - "pretty-bytes": "^5.4.1", - "upath": "^1.2.0", - "webpack-sources": "^1.4.3", - "workbox-build": "6.5.3" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "webpack-sources": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", - "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", - "requires": { - "source-list-map": "^2.0.0", - "source-map": "~0.6.1" - } - } - } - }, - "workbox-window": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.3.tgz", - "integrity": "sha512-GnJbx1kcKXDtoJBVZs/P7ddP0Yt52NNy4nocjBpYPiRhMqTpJCNrSL+fGHZ/i/oP6p/vhE8II0sA6AZGKGnssw==", - "requires": { - "@types/trusted-types": "^2.0.2", - "workbox-core": "6.5.3" - } - }, - "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==" - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "requires": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "requires": {} - }, - "xdg-basedir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", - "dev": true - }, - "xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" - }, - "xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "optional": true - }, - "xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "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": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "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==" - }, - "yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", - "dev": true, - "requires": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "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==" - } } } diff --git a/web/package.json b/web/package.json index 1947eaba..bb84ff16 100644 --- a/web/package.json +++ b/web/package.json @@ -1,43 +1,24 @@ { "name": "ntfy", "version": "1.0.0", - "url": "https://github.com/binwiederhier/ntfy", - "author": "Philipp C. Heckel ", "private": true, "scripts": { - "start": "react-scripts start", - "start-electron": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"", - "build": "react-scripts build", - "build-electron": "react-scripts build --em.main=build/electron.js && electron-builder", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "main": "public/electron.js", - "homepage": "./", - "build": { - "appId": "io.heckel.ntfy", - "files": [ - "build/**/*", - "node_modules/**/*", - "public/**/*" - ], - "directories":{ - "buildResources": "assets" - }, - "linux": { - "target": [ - "AppImage" - ] - } + "start": "NODE_OPTIONS=\"--enable-source-maps\" vite", + "build": "vite build", + "serve": "vite preview", + "format": "prettier . --write", + "format:check": "prettier . --check", + "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" }, "dependencies": { - "@emotion/react": "^11.8.2", - "@emotion/styled": "^11.8.1", + "@emotion/cache": "^11.11.0", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", - "electron-is-dev": "^2.0.0", + "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", @@ -46,16 +27,25 @@ "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", + "react-remark": "^2.1.0", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", - "stacktrace-js": "^2.0.2" + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0", + "stylis-plugin-rtl": "^2.1.1" }, "devDependencies": { - "concurrently": "^7.1.0", - "electron": "^18.2.0", - "electron-builder": "^23.0.3", - "wait-on": "^6.0.1" + "@vitejs/plugin-react": "^4.0.0", + "eslint": "^8.41.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "prettier": "^2.8.8", + "vite": "^4.3.9", + "vite-plugin-pwa": "^0.15.0" }, "browserslist": { "production": [ @@ -68,5 +58,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "prettier": { + "printWidth": 140 } } diff --git a/web/public/config.js b/web/public/config.js index cd5fbf05..63bc97bd 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -1,9 +1,21 @@ -// Configuration injected by the ntfy server. +// THIS FILE IS JUST AN EXAMPLE // -// This file is just an example. It is removed during the build process. -// The actual config is dynamically generated server-side. +// It is removed during the build process. The actual config is dynamically +// generated server-side and served by the ntfy server. +// +// During web development, you may change values here for rapid testing. var config = { - appRoot: "/", - disallowedTopics: ["docs", "static", "file", "app", "settings"] + base_url: window.location.origin, // Change to test against a different server + app_root: "/", + enable_login: true, + enable_signup: true, + enable_payments: false, + enable_reservations: true, + enable_emails: true, + enable_calls: true, + enable_web_push: true, + billing_contact: "", + web_push_public_key: "", + disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], }; diff --git a/web/public/electron.js b/web/public/electron.js deleted file mode 100644 index c016a3bf..00000000 --- a/web/public/electron.js +++ /dev/null @@ -1,42 +0,0 @@ -const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron'); -const isDev = require('electron-is-dev'); -const path = require('path'); - -let mainWindow; - -const createWindow = () => { - mainWindow = new BrowserWindow({width: 900, height: 680}); - mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`); - mainWindow.on('closed', () => mainWindow = null); -}; - -const createTray = () => { - const icon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAACsZJREFUWAmtWFlsXFcZ/u82++Jt7IyT2Em6ZFHTpAtWIzspEgjEUhA8VNAiIYEQUvuABBIUwUMkQIVKPCIoEiABLShISEBbhFJwIGRpIKRpbNeJ7bh2HHvssR3PPnPnLnzfmRlju6EQqUc+c++c8y/fv54z1uQOh+/7Glh0TD59TE/TND7lnfa4/64OKsM071QoeZpA/y9WWvk/B4XCC06TUC+Xyw8HTXNQ1+Ww6PpOrMebewXxvBueJ6/XHOdMJBL5J9Y97m2R0SS/wweE6JxkGx5dilWr1S/7dXsEa2o4+LyFmcFcaL5zbX3Y9gh5hpeWYpSB9XV5/H678V89BGYDXnHJlCsWn4gHrGc1K9CXxferOdvPOOKUfF8cH7nUyCtklQZXih/VNNlmirk3GdBSoIcRswW7/vVkLPYi5W2Uze8bh7J+4wLfh4dViFx5/nmrUi7/MhGNvrCkBfpeWqnW/7BUdadqntQ8zwr6vhUV34xpYnDynWvcmwQNaclDXsqgLMqkocPDw7fNx7d5qIX+/PmJxKGD6VdDkeh7ztyqOFfrokGCEWiiZ1mp0uITnuKAosaT7+pNxMYTyefutcQfbA+b1XLpH5fnF97/yD335Fu6mqTqsclDINBVmI4fDxw80KPAvJSt1MZtMcLiGxYUu83p4UkgnJZlqcl3LAj3WnTkIS9lUBYNPJjueVWgg7qocyOgliFqjZsg8gq5tRdiieQTf1gq15Y8CUbRZtyWOzZwc8lEqS3PTCtgqd13ieO68BQ2uNl64tXAewktrFuX2mPdkWAxn3sxnmx7sqUTJGqso8MGS9tbXFz8DMH8bblUX3T9QARVi8RV8qljfcJy0zRlaf6mzHEuzEtmekqCoZB4rqp0OmudHtUnlEWZlE0d1EWd1N3EozourcO65pw4eTIZQTW9VazJtbqvw9XwKVFQMsKDBuNhtp4uvGGFI+IDgKnpMjYyIis3ZsQMBIR7pONsIaMsyqRs6ohY1rPUSd3EQFDqo+kdZ3Fh4aupbdu+99uFQr2A1CBs4uEAjZjIFUMHi4dVxMXzCdCXQj4vBrwVCofl0ulTcv/DAxJJJBUPc8mpoyI2JDw7bFyT+ifTcSubyXytJ51+roWBxwG9Q73WWjZ7eSUU3//nXM0NI+x0PBGrTSgsLS9JFuFxHFrvSqIrJV279gi6tjiVspTza3JjZhY+0CQZj0mlWJSeHTslCro6eFqymCcVVN77kkGjs1p4sy2VOoSlOrFwT+XR+PjkgGaZ+ycKVbRTYUdVrmaImCvzk1dlFCEJdHRJ284+ie/ol0h7p7jFvExcvCCXzp2Rqem3pAMAiqWS6JGYhFI9Mjo6KjevXVUyKEuFHrKpY6JQ8TXT3D8+OTkAHBw6o6LCFo9ag3o4JtlCyTHEt5AxKvS6YUi5kJeZG3Py0NAxlLcJ9xti+K7Mjo/JfGZRuvv6Ze+9+yWEhDZAvzg3JyhX2d6/S7q6e+TimdOS7ElLKBZDwqvmj6rztayr1fVI1IoXi4PAcYZY1tPEEO1wEVlXgRFBDcmIXTqJsS+XyhKLJ5A/OpIVXXptWUYv/UvaenfIocEhMQ2EzHHErlXFCgQl3paU1eVl6QAY8sQTCSmVihKJx1V/ogvgIYF/pACdcMBhqONoHhF88/2d+bojyA6cRvje2IdFjoSjUSnBS8hgyS9lZOzKFdmPxO3o6gQIGzwuDn1dVSCtCKPy1pZXlATXqUsVYMLRmKo87vP4Y1ioqwCdCegmMYx3W/VPn8RrSDwwIMMbcEjkYo29JZVOy+ybI7K4eksODx1VSqvligpReSVLgySM/FI5h2q062jNyL3s7FtoAyGJIlx1225UmwJF6aJRJ3XzHXO9bWvsJa3jQFlBJkz6iuXdu32HzM7MyP0PPNgAU6ko4Qzp6b+flr8MD9OYJg9CwtzL5+T65ITs2bsP3mGxN/ZbBcOn0sk20gAkLQ+huXpFi8vkoY9AoyDjxTR1mbo6Ltt275HpN0dlNxQE40mVM8Ajjxx9VAGhAvQR1akZFCq799ADysMuQqOxh2FNmamEaz51ItGLfFD9+oUJoZkLowHoFA2mljUacqOMflKuVmHpfmnfvlMuvXZeStmMBIMhcWEdjgFJtrUjXI0KchAuAg0ilxLJNoRVBxhIBm0TjjKAuqjTqTs3CQZ6QUUMGFW7eiWMUg6w+yo8YMW7DqtqlZLkUDV2ISfd29KyDwk9MjYmMyOXxQIIKuShqo4VGFNBEgeDQYqVam5N5tEePFQgURIUBCsd1EWd1XrtDUUMLARD9bKaK5ytQ2Gb75g8WMiEP6VkfnZGevv6UF1vSBW5E0PFDAweFRvlfun8WVmamhDNrkmweQ0pwaPt6M4m8mgKTTFXqcrV0ZH1FKBg6qAu6qTuJiCV1Cp2Q0NDr9Uq5Ym+oMEDlSewsoRwrVBEaij7AJ4s7zrOpumxEdm15y6558GHJVe1Zezy6zJx6aJkpq5JFB4z6zVZmBiX1VWUP0IY4CFMYcpQdZ3xqIs6oftCE5DHKwd0q/tzOV8svdDb3nk8VnG9qmgQC0ZURz8Ur91alXgSByZ6ES9kZZTr/PR16UOCh+7dq0CWyyXJ4xqCQ0nKt9YQSlPue2gAeYZzD7yNLk0wmqAreb2WYSxAJ8Dget64wxtEBlDaqVOn/K5dB67t6+t5MhoMJuc8w8UPKiQ9CQR9JK5czhZAQxPt7TKF3OiAIisUViAD2Lg5d0P2HDgoKeRaW0enyqVwBJcO5fFG5dqa7h406qaeX8384uTZL5w9+UqxhYHFp0YLIYA9ddfu3T+4UJF6Rg+YAc9D0+RoIGP1ULhpWspr10evyK7+ftWTrk9PS/++A9KZSm26cih2mMOErem6n/ZsZwA2TM/MPHXs2LEftnSTbh0Q36mIIbx44cLvOnu3f+xUwbWLmoHTCUlF6g2jBQo/GnFrnGNqSHdvr+rIKGMW1KahwEBdzHft98aNwMr8zd8/NDDwccihc0hLi3GubRjY0Bm6H19fPvnZI4c/fHd7PJ2peXYZ+WQ26JufZELjQ6lbAQtnWre0d3apY8TFIdtAo+Qri6mupsB49lBMC+QXF0YefObZT8j0eKWlswVjEyCCOXHihPGb575VCvVuf3lvetsH9rXF0rla3cnhpoIGjgsUPhR3I4TMKYJQV1Z6WO02aEjHa5mNe3OPW3OPRHVrbXFh9Ocvv/KR1372owx1Pf3005uc35Ddgtd8rsf06IdS5777zZ+mUqmPzjm6TPpmvayZOq4LyATeCzkanmiy4qEuC/yXiO8CSMRzvLs1x9phepLNZl868sy3Pyen/5hd1/EfRvWmuvSWNeaRS/RkPDI4+NjE1NSXEoXlpaNB1zqo20abi59/vu/UfM2pie7WUDVq8l3wTwnskeZ+zTbIQ17KoCzKpGzq2KqX32/roRbh8ePHdUzl0s9/5Rv9n/7go19MxCKfCkZiu3V06wrO5gocxL7Dgd/IEobEMH6rejg+auXidL5Y/vWv/vTX53/y/e/MkGajTH7fOt4RUJOY1df4RdtY6ICFRzqTySOhUOA+3Ai3o31H1ZbnlXBruFmt2iMrudy5xx9//BzWV7nXDBGN2xpjbt/5oGUEdhtO3iD47xZOvm8a5CHvpsV38wsUaMwBWsz3rbK5xr0mzdv2t9Jv/f5vhsF4J+Q63IUAAAAASUVORK5CYII='); - const tray = new Tray(icon); - - const contextMenu = Menu.buildFromTemplate([ - { label: 'Quit' } - ]); - - tray.setContextMenu(contextMenu); - tray.setToolTip('This is my application'); - tray.setTitle('This is my title'); -} - -app.on('ready', () => { - createWindow(); - createTray(); -}); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - - -app.on('activate', () => { - if (mainWindow === null) { - createWindow(); - } -}); diff --git a/web/public/home.html b/web/public/home.html deleted file mode 100644 index 3e3a95d8..00000000 --- a/web/public/home.html +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - ntfy.sh | Send push notifications to your phone via PUT/POST - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Send push notifications to your phone or desktop via PUT/POST

-

- ntfy (pronounce: notify) is a simple HTTP-based pub-sub notification service. - It allows you to send notifications to your phone or desktop via scripts from any computer, - entirely without signup, cost or setup. It's also open source if you want to run your own. -

-
- - - - - - - -
- -

Publishing messages

-

- Publishing messages can be done via PUT or POST. Topics are created on the fly by subscribing or publishing to them. - Because there is no sign-up, the topic is essentially a password, so pick something that's not easily guessable. -

-

- Here's an example showing how to publish a message using a POST request (via curl -d): -

- - curl -d "Backup successful 😀" ntfy.sh/mytopic - -

- There are more features related to publishing messages: You can set a - notification priority, a title, - and tag messages. - Here's an example using some of them together: -

- - curl \
-   -H "Title: Unauthorized access detected" \
-   -H "Priority: urgent" \
-   -H "Tags: warning,skull" \
-   -d "Remote access to $(hostname) detected. Act right away." \
-   ntfy.sh/mytopic -
-

- Here's what that looks like in the Android app: -

-
- -
Urgent notification with pop-over
-
- -

Subscribe to a topic

-

- You can create and subscribe to a topic either using your phone, - in this web UI, or in your own app by subscribing via the API. -

- -

Subscribe from your phone

-

- Simply get the app and start publishing messages. To learn more about the app, - check out the documentation. -

-

- - - -

-

- Here's a video showing the app in action: -

-
- -
Sending push notifications to your Android phone
-
- -

Subscribe via web app

-

- Subscribe to topics in the web app and receive messages as desktop notification. - It is available at ntfy.sh/app. -

-
- -
ntfy web app, available at ntfy.sh/app
-
- -

Subscribe using the API

-

- There's a super simple API that you can use to integrate your own app. You can consume - a JSON stream, - an SSE/EventSource stream, - a plain text stream, - or via WebSockets. -

-

- Here's an example for JSON. The connection stays open, so you can retrieve messages as they come in: -

- - $ curl -s ntfy.sh/mytopic/json
- {"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}
- {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}
- {"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}
- ... -
-

- Here's a short video demonstrating it in action: -

-
- -
Subscribing to the JSON stream with curl
-
- -

Check out the docs!

-

- ntfy has so many more features and you can learn about all of them in the documentation - (I tried my very best to make it the best docs ever 😉, not sure if I succeeded, hehe). -

-
- -
Check out the documentation
-
- -

100% open source & forever free

-

- I love free software, and I'm doing this because it's fun. I have no bad intentions, and I will - never monetize or sell your information. This service will always stay - free and open. - You can read more in the FAQs and in the privacy policy. -

- -
Made with ❤️ by Philipp C. Heckel
-
- - - - diff --git a/web/public/index.html b/web/public/index.html deleted file mode 100644 index 33f4828c..00000000 --- a/web/public/index.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - ntfy web - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - diff --git a/web/public/static/css/app.css b/web/public/static/css/app.css new file mode 100644 index 00000000..213859c0 --- /dev/null +++ b/web/public/static/css/app.css @@ -0,0 +1,11 @@ +/* web app styling overrides */ + +a, +a:visited { + color: #338574; +} + +a:hover { + text-decoration: none; + color: #317f6f; +} diff --git a/web/public/static/css/fonts.css b/web/public/static/css/fonts.css index d14bad03..2cf00a3c 100644 --- a/web/public/static/css/fonts.css +++ b/web/public/static/css/fonts.css @@ -2,40 +2,32 @@ /* roboto-300 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 300; - src: local(''), - url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + font-family: "Roboto"; + font-style: normal; + font-weight: 300; + src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2"); } /* roboto-regular - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 400; - src: local(''), - url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + font-family: "Roboto"; + font-style: normal; + font-weight: 400; + src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2"); } /* roboto-500 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 500; - src: local(''), - url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + font-family: "Roboto"; + font-style: normal; + font-weight: 500; + src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2"); } /* roboto-700 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 700; - src: local(''), - url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + font-family: "Roboto"; + font-style: normal; + font-weight: 700; + src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2"); } diff --git a/web/public/static/css/home.css b/web/public/static/css/home.css deleted file mode 100644 index feeaa7ee..00000000 --- a/web/public/static/css/home.css +++ /dev/null @@ -1,280 +0,0 @@ -/* general styling */ - -html, body { - font-family: 'Roboto', sans-serif; - font-weight: 400; - font-size: 1.1em; - color: #444; - margin: 0; - padding: 0; -} - -html { - /* prevent scrollbar from repositioning website: - * https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */ - overflow-y: scroll; -} - -a, a:visited { - color: #338574; -} - -a:hover { - text-decoration: none; - color: #317f6f; -} - -h1 { - margin-top: 35px; - margin-bottom: 30px; - font-size: 2.5em; - word-wrap: break-word; /* For very long topics */ - padding-right: 40px; /* For the X on the detail page */ - font-weight: 300; - color: #666; -} - -h2 { - margin-top: 30px; - margin-bottom: 5px; - font-size: 1.8em; - font-weight: 300; - color: #333; -} - -h3 { - margin-top: 25px; - margin-bottom: 5px; - font-size: 1.3em; - font-weight: 300; - color: #333; -} - -p { - margin-top: 10px; - margin-bottom: 20px; - line-height: 160%; - font-weight: 400; -} - -p.smallMarginBottom { - margin-bottom: 10px; -} - -b { - font-weight: 500; -} - -tt { - background: #eee; - padding: 2px 7px; - border-radius: 3px; -} - -code { - display: block; - background: #eee; - font-family: monospace; - padding: 20px; - border-radius: 3px; - margin-top: 10px; - margin-bottom: 20px; - overflow-x: auto; - white-space: nowrap; -} - -/* Main page */ - -#main { - max-width: 900px; - margin: 0 auto 50px auto; - padding: 0 10px; -} - -#error { - color: darkred; - font-style: italic; -} - -#ironicCenterTagDontFreakOut { - color: #666; -} - -/* Anchors */ - -.anchor .anchorLink { - color: #ccc; - text-decoration: none; - padding: 0 5px; - visibility: hidden; -} - -.anchor:hover .anchorLink { - visibility: visible; -} - -.anchor .anchorLink:hover { - color: #338574; - visibility: visible; -} - -/* Figures */ - -figure { - text-align: center; -} - -figure img, figure video { - filter: drop-shadow(3px 3px 3px #ccc); - border-radius: 7px; - max-width: 100%; -} - -figure video { - width: 100%; - max-height: 450px; -} - -figcaption { - text-align: center; - font-style: italic; - padding-top: 10px; -} - -/* Screenshots */ - -#screenshots { - text-align: center; -} - -#screenshots img { - height: 190px; - margin: 3px; - border-radius: 5px; - filter: drop-shadow(2px 2px 2px #ddd); -} - -#screenshots .nowrap { - white-space: nowrap; -} - -/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */ - -.lightbox { - opacity: 0; - visibility: hidden; - position: fixed; - left:0; - right: 0; - top: 0; - bottom: 0; - z-index: -1; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s ease-in; -} - -.lightbox.show { - background-color: rgba(0,0,0, 0.75); - opacity: 1; - visibility: visible; - z-index: 1000; -} - -.lightbox img { - max-width: 90%; - max-height: 90%; - filter: drop-shadow(5px 5px 10px #222); - border-radius: 5px; -} - -.lightbox .close-lightbox { - cursor: pointer; - position: absolute; - top: 30px; - right: 30px; - width: 20px; - height: 20px; -} - -.lightbox .close-lightbox::after, -.lightbox .close-lightbox::before { - content: ''; - width: 3px; - height: 20px; - background-color: #ddd; - position: absolute; - border-radius: 5px; - transform: rotate(45deg); -} - -.lightbox .close-lightbox::before { - transform: rotate(-45deg); -} - -.lightbox .close-lightbox:hover::after, -.lightbox .close-lightbox:hover::before { - background-color: #fff; -} - -/* Header */ - -#header { - background: #338574; - height: 130px; -} - -#header #headerBox { - max-width: 900px; - margin: 0 auto; - padding: 0 10px; -} - -#header #logo { - margin-top: 23px; - float: left; -} - -#header #name { - float: left; - color: white; - font-size: 2.6em; - font-weight: 300; - margin: 35px 0 0 20px; -} - -#header ol { - list-style-type: none; - float: right; - margin-top: 80px; -} - -#header ol li { - display: inline-block; - margin: 0 10px; - font-weight: 400; -} - -#header ol li a, nav ol li a:visited { - color: white; - text-decoration: none; -} - -#header ol li a:hover { - text-decoration: underline; -} - -li { - padding: 4px 0; - margin: 4px 0; - font-size: 0.9em; -} - - -/* Hide top menu SMALL SCREEN */ -@media only screen and (max-width: 780px) { - #header ol { - display: none; - } -} diff --git a/web/public/static/fonts/roboto-v29-latin-300.woff b/web/public/static/fonts/roboto-v29-latin-300.woff deleted file mode 100644 index 5565042e..00000000 Binary files a/web/public/static/fonts/roboto-v29-latin-300.woff and /dev/null differ diff --git a/web/public/static/fonts/roboto-v29-latin-500.woff b/web/public/static/fonts/roboto-v29-latin-500.woff deleted file mode 100644 index c9eb5cab..00000000 Binary files a/web/public/static/fonts/roboto-v29-latin-500.woff and /dev/null differ diff --git a/web/public/static/fonts/roboto-v29-latin-700.woff b/web/public/static/fonts/roboto-v29-latin-700.woff deleted file mode 100644 index a5d98fc6..00000000 Binary files a/web/public/static/fonts/roboto-v29-latin-700.woff and /dev/null differ diff --git a/web/public/static/fonts/roboto-v29-latin-regular.woff b/web/public/static/fonts/roboto-v29-latin-regular.woff deleted file mode 100644 index 86b38637..00000000 Binary files a/web/public/static/fonts/roboto-v29-latin-regular.woff and /dev/null differ diff --git a/web/public/static/images/apple-touch-icon.png b/web/public/static/images/apple-touch-icon.png new file mode 100644 index 00000000..8f890509 Binary files /dev/null and b/web/public/static/images/apple-touch-icon.png differ diff --git a/web/public/static/images/favicon.ico b/web/public/static/images/favicon.ico new file mode 100644 index 00000000..857fa54c Binary files /dev/null and b/web/public/static/images/favicon.ico differ diff --git a/web/public/static/images/mask-icon.svg b/web/public/static/images/mask-icon.svg new file mode 100644 index 00000000..32fced6d --- /dev/null +++ b/web/public/static/images/mask-icon.svg @@ -0,0 +1,20 @@ + + + + + + + diff --git a/web/public/static/img/ntfy.png b/web/public/static/images/ntfy.png similarity index 100% rename from web/public/static/img/ntfy.png rename to web/public/static/images/ntfy.png diff --git a/web/public/static/images/pwa-192x192.png b/web/public/static/images/pwa-192x192.png new file mode 100644 index 00000000..8aaebcc4 Binary files /dev/null and b/web/public/static/images/pwa-192x192.png differ diff --git a/web/public/static/images/pwa-512x512.png b/web/public/static/images/pwa-512x512.png new file mode 100644 index 00000000..d9003a19 Binary files /dev/null and b/web/public/static/images/pwa-512x512.png differ diff --git a/web/public/static/img/android-video-overview.mp4 b/web/public/static/img/android-video-overview.mp4 deleted file mode 100644 index cf295099..00000000 Binary files a/web/public/static/img/android-video-overview.mp4 and /dev/null differ diff --git a/web/public/static/img/android-video-subscribe-api.mp4 b/web/public/static/img/android-video-subscribe-api.mp4 deleted file mode 100644 index d73e5c6e..00000000 Binary files a/web/public/static/img/android-video-subscribe-api.mp4 and /dev/null differ diff --git a/web/public/static/img/badge-appstore.png b/web/public/static/img/badge-appstore.png deleted file mode 100644 index 0b4ce1c0..00000000 Binary files a/web/public/static/img/badge-appstore.png and /dev/null differ diff --git a/web/public/static/img/badge-fdroid.png b/web/public/static/img/badge-fdroid.png deleted file mode 100644 index 9464d38a..00000000 Binary files a/web/public/static/img/badge-fdroid.png and /dev/null differ diff --git a/web/public/static/img/badge-googleplay.png b/web/public/static/img/badge-googleplay.png deleted file mode 100644 index 36036d8b..00000000 Binary files a/web/public/static/img/badge-googleplay.png and /dev/null differ diff --git a/web/public/static/img/favicon.png b/web/public/static/img/favicon.png deleted file mode 100644 index 92312fea..00000000 Binary files a/web/public/static/img/favicon.png and /dev/null differ diff --git a/web/public/static/img/screenshot-docs.png b/web/public/static/img/screenshot-docs.png deleted file mode 100644 index 4345ded4..00000000 Binary files a/web/public/static/img/screenshot-docs.png and /dev/null differ diff --git a/web/public/static/img/screenshot-phone-add.jpg b/web/public/static/img/screenshot-phone-add.jpg deleted file mode 100644 index f728ec99..00000000 Binary files a/web/public/static/img/screenshot-phone-add.jpg and /dev/null differ diff --git a/web/public/static/img/screenshot-phone-popover.png b/web/public/static/img/screenshot-phone-popover.png deleted file mode 100644 index 31d15152..00000000 Binary files a/web/public/static/img/screenshot-phone-popover.png and /dev/null differ diff --git a/web/public/static/js/home.js b/web/public/static/js/home.js deleted file mode 100644 index 80b14055..00000000 --- a/web/public/static/js/home.js +++ /dev/null @@ -1,84 +0,0 @@ - -/* All the things */ - -let currentUrl = window.location.hostname; -if (window.location.port) { - currentUrl += ':' + window.location.port -} - -/* Screenshots */ -const lightbox = document.getElementById("lightbox"); - -const showScreenshotOverlay = (e, el, index) => { - lightbox.classList.add('show'); - document.addEventListener('keydown', nextScreenshotKeyboardListener); - return showScreenshot(e, index); -}; - -const showScreenshot = (e, index) => { - const actualIndex = resolveScreenshotIndex(index); - lightbox.innerHTML = '
' + screenshots[actualIndex].innerHTML; - lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); }; - currentScreenshotIndex = actualIndex; - e.stopPropagation(); - return false; -}; - -const nextScreenshot = (e) => { - return showScreenshot(e, currentScreenshotIndex+1); -}; - -const previousScreenshot = (e) => { - return showScreenshot(e, currentScreenshotIndex-1); -}; - -const resolveScreenshotIndex = (index) => { - if (index < 0) { - return screenshots.length - 1; - } else if (index > screenshots.length - 1) { - return 0; - } - return index; -}; - -const hideScreenshotOverlay = (e) => { - lightbox.classList.remove('show'); - document.removeEventListener('keydown', nextScreenshotKeyboardListener); -}; - -const nextScreenshotKeyboardListener = (e) => { - switch (e.keyCode) { - case 37: - previousScreenshot(e); - break; - case 39: - nextScreenshot(e); - break; - } -}; - -let currentScreenshotIndex = 0; -const screenshots = [...document.querySelectorAll("#screenshots a")]; -screenshots.forEach((el, index) => { - el.onclick = (e) => { return showScreenshotOverlay(e, el, index); }; -}); - -lightbox.onclick = hideScreenshotOverlay; - -// Add anchor links -document.querySelectorAll('.anchor').forEach((el) => { - if (el.hasAttribute('id')) { - const id = el.getAttribute('id'); - const anchor = document.createElement('a'); - anchor.innerHTML = `#`; - el.appendChild(anchor); - } -}); - -// Change ntfy.sh url and protocol to match self-hosted one -document.querySelectorAll('.ntfyUrl').forEach((el) => { - el.innerHTML = currentUrl; -}); -document.querySelectorAll('.ntfyProtocol').forEach((el) => { - el.innerHTML = window.location.protocol + "//"; -}); diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json new file mode 100644 index 00000000..ce1d88bb --- /dev/null +++ b/web/public/static/langs/ar.json @@ -0,0 +1,334 @@ +{ + "action_bar_logo_alt": "شعار ntfy", + "action_bar_settings": "اﻹعدادات", + "action_bar_clear_notifications": "محو كافة الإشعارات", + "action_bar_unsubscribe": "إلغاء الاشتراك", + "message_bar_show_dialog": "إظهار مربع حوار النشر", + "message_bar_publish": "نشر الرسالة", + "nav_topics_title": "المواضيع التي تم الاشتراك فيها", + "nav_button_all_notifications": "كافة الإشعارات", + "nav_button_settings": "اﻹعدادات", + "nav_button_documentation": "الدليل", + "nav_button_publish_message": "نشر الإشعار", + "nav_button_subscribe": "اشترك في الموضوع", + "nav_button_connecting": "جارٍ الاتصال", + "alert_notification_permission_required_title": "تم تعطيل الإشعارات", + "alert_notification_permission_required_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.", + "notifications_list": "قائمة الإشعارات", + "notifications_list_item": "إشعار", + "notifications_mark_read": "وضع علامة كمقروء", + "notifications_tags": "الوسوم", + "notifications_priority_x": "الأولوية {{priority}}", + "notifications_new_indicator": "إشعار جديد", + "notifications_attachment_image": "صورة مرفقة", + "notifications_attachment_copy_url_button": "نسخ عنوان URL", + "notifications_attachment_open_title": "انتقل إلى {{url}}", + "notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}", + "notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل", + "notifications_attachment_file_image": "ملف الصورة", + "notifications_attachment_file_video": "ملف فيديو", + "notifications_attachment_file_audio": "ملف صوتي", + "notifications_attachment_file_app": "ملف تطبيق Android", + "notifications_attachment_file_document": "وثيقة أخرى", + "notifications_click_copy_url_button": "نسخ الرابط", + "notifications_click_open_button": "فتح الرابط", + "notifications_actions_open_url_title": "انتقل إلى {{url}}", + "notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب", + "action_bar_send_test_notification": "إرسال إشعار للاختبار", + "action_bar_show_menu": "عرض القائمة", + "message_bar_type_message": "اكتب رسالة هنا", + "alert_not_supported_title": "الإشعارات غير مدعومة", + "alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.", + "message_bar_error_publishing": "خطأ خلال نشر الإشعار", + "notifications_delete": "حذف", + "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة", + "action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات", + "action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات", + "alert_notification_permission_required_button": "امنح الآن", + "notifications_attachment_open_button": "فتح المرفق", + "notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة", + "notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة", + "notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.", + "notifications_none_for_any_title": "لم تتلق أية إشعارات.", + "notifications_no_subscriptions_title": "يبدو أنك لا تملك أي اشتراكات بعد.", + "notifications_example": "مثال", + "notifications_loading": "تحميل الإشعارات…", + "publish_dialog_title_topic": "أنشُر إلى {{topic}}", + "publish_dialog_title_no_topic": "انشُر الإشعار", + "publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا", + "publish_dialog_priority_min": "أولوية دنيا", + "publish_dialog_priority_low": "أولوية منخفضة", + "publish_dialog_priority_default": "الأولوية الافتراضية", + "publish_dialog_priority_high": "أولوية عالية", + "publish_dialog_base_url_label": "الرابط التشعبي للخدمة", + "publish_dialog_priority_max": "أولوية قصوى", + "publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", + "publish_dialog_title_label": "العنوان", + "publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص", + "publish_dialog_message_label": "الرسالة", + "publish_dialog_message_placeholder": "اكتب رسالة هنا", + "publish_dialog_tags_label": "الوسوم", + "publish_dialog_priority_label": "الأولوية", + "publish_dialog_click_placeholder": "العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار", + "publish_dialog_email_label": "البريد الإلكتروني", + "publish_dialog_filename_label": "اسم الملف", + "publish_dialog_attach_label": "الرابط التشعبي URL للمرفق", + "publish_dialog_filename_placeholder": "اسم ملف المرفق", + "publish_dialog_delay_label": "تأخير", + "publish_dialog_delay_reset": "إزالة تأخر التسليم", + "publish_dialog_chip_click_label": "انقر على عنوان URL", + "publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني", + "publish_dialog_chip_attach_file_label": "إرفاق ملف محلي", + "publish_dialog_chip_topic_label": "تغيير الموضوع", + "publish_dialog_button_cancel_sending": "إلغاء الإرسال", + "publish_dialog_button_send": "أرسل", + "publish_dialog_checkbox_publish_another": "نشر آخر", + "publish_dialog_attached_file_title": "الملف المرفق:", + "publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق", + "publish_dialog_attached_file_remove": "إزالة الملف المرفق", + "publish_dialog_drop_file_here": "قم بإسقاط ملف هنا", + "emoji_picker_search_placeholder": "البحث عن رمز تعبيري", + "emoji_picker_search_clear": "مسح البحث", + "subscribe_dialog_subscribe_title": "الإشتراك في الموضوع", + "subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر", + "subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة", + "subscribe_dialog_subscribe_button_subscribe": "اشترِك", + "subscribe_dialog_login_title": "تسجيل الدخول مطلوب", + "subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil", + "subscribe_dialog_login_password_label": "كلمة المرور", + "subscribe_dialog_login_button_login": "الولوج", + "subscribe_dialog_error_user_anonymous": "مجهول", + "prefs_notifications_title": "الإشعارات", + "prefs_notifications_sound_title": "صوت الإشعار", + "prefs_notifications_sound_no_sound": "لا صوت", + "prefs_notifications_min_priority_description_any": "عرض جميع الإشعارات، بغض النظر عن الأولوية", + "prefs_notifications_delete_after_title": "حذف الإشعارات", + "prefs_notifications_delete_after_never": "أبداً", + "prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات", + "prefs_notifications_delete_after_one_day": "بعد يوم واحد", + "prefs_notifications_delete_after_one_month": "بعد شهر واحد", + "prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا", + "prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", + "prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد", + "prefs_users_table": "قائمة المستخدمين", + "prefs_users_edit_button": "تعديل المستخدم", + "prefs_users_table_user_header": "المستخدم", + "prefs_users_table_base_url_header": "الرابط التشعبي للخدمة", + "priority_default": "افتراضية", + "prefs_users_dialog_username_label": "اسم المستخدم، على سبيل المثال phil", + "prefs_users_dialog_button_cancel": "إلغاء", + "prefs_users_dialog_button_add": "اضافة", + "prefs_users_dialog_button_save": "حفظ", + "prefs_appearance_title": "المظهر", + "prefs_appearance_language_title": "اللغة", + "error_boundary_gathering_info": "جمع مزيد من المعلومات …", + "error_boundary_unsupported_indexeddb_title": "التصفح الخاص غير مدعوم", + "priority_high": "عالية", + "priority_max": "قصوى", + "error_boundary_title": "أوه لا ، لقد تحطم ntfy", + "prefs_users_delete_button": "حذف المستخدم", + "prefs_users_add_button": "إضافة مستخدم", + "prefs_notifications_min_priority_any": "مهما كانت الأولوية", + "prefs_notifications_delete_after_one_week": "بعد أسبوع واحد", + "prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات", + "prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", + "prefs_users_title": "إدارة المستخدمين", + "prefs_users_dialog_title_add": "إضافة مستخدم", + "prefs_users_dialog_title_edit": "تعديل المستخدم", + "prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh", + "publish_dialog_button_cancel": "إلغاء", + "publish_dialog_message_published": "تم نشر الإشعار", + "prefs_users_dialog_password_label": "كلمة المرور", + "publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com", + "publish_dialog_progress_uploading": "جارٍ التحميل…", + "publish_dialog_topic_label": "اسم الموضوع", + "publish_dialog_topic_reset": "إعادة تعيين الموضوع", + "publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني", + "publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com", + "publish_dialog_other_features": "ميزات أخرى:", + "publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL", + "subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", + "prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها", + "publish_dialog_chip_delay_label": "تأخير التسليم", + "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", + "subscribe_dialog_subscribe_button_cancel": "إلغاء", + "common_back": "الرجوع", + "prefs_notifications_sound_play": "تشغيل الصوت المحدد", + "prefs_notifications_min_priority_title": "أولوية دنيا", + "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", + "notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.", + "publish_dialog_click_label": "الرابط التشعبي URL للنقر", + "publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup", + "publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "إزالة عنوان URL للمرفق", + "subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به", + "common_save": "حفظ", + "common_add": "إضافة", + "signup_form_username": "إسم المستخدم", + "signup_form_confirm_password": "تأكيد كلمة المرور", + "login_title": "تسجيل الدخول إلى حسابك ntfy", + "login_form_button_submit": "الولوج", + "login_link_signup": "إنشاء حساب", + "login_disabled": "تم تعطيل تسجيل الدخول", + "action_bar_account": "الحساب", + "action_bar_change_display_name": "تغيير الإسم المعروض", + "signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات", + "action_bar_reservation_add": "حجز الموضوع", + "action_bar_reservation_edit": "تغيير الحجز", + "action_bar_profile_title": "الملف التعريفي", + "action_bar_profile_settings": "اﻹعدادات", + "action_bar_profile_logout": "الخروج", + "action_bar_sign_in": "الولوج", + "action_bar_sign_up": "إنشاء حساب", + "nav_button_account": "الحساب", + "nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro", + "reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول", + "subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم", + "subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل", + "account_basics_title": "الحساب", + "account_basics_username_title": "إسم المستخدم", + "account_basics_username_description": "مرحبًا، هذا أنت ❤", + "account_basics_username_admin_tooltip": "أنت مدير", + "account_basics_password_title": "كلمة المرور", + "account_basics_password_description": "غيّر كلمة مرور حسابك", + "account_basics_password_dialog_title": "تغيير كلمة المرور", + "account_basics_password_dialog_current_password_label": "كلمة المرور الحالية", + "account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة", + "account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور", + "account_basics_password_dialog_button_submit": "تغيير كلمة المرور", + "account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة", + "account_usage_title": "الإستخدام", + "account_usage_of_limit": "من {{limit}}", + "account_usage_unlimited": "غير محدود", + "account_basics_tier_title": "نوع الحساب", + "account_basics_tier_description": "مستوى قوة حسابك", + "account_basics_tier_admin": "مدير", + "account_basics_tier_free": "مجاني", + "account_basics_tier_upgrade_button": "الترقية إلى Pro", + "account_basics_tier_change_button": "تغيير", + "account_basics_tier_manage_billing_button": "إدارة الفوترة", + "account_usage_messages_title": "الرسائل المنشورة", + "account_usage_reservations_title": "المواضيع المحجوزة", + "account_usage_attachment_storage_title": "تخزين المرفقات", + "account_delete_title": "حذف الحساب", + "account_delete_description": "احذف حسابك نهائيا", + "account_delete_dialog_label": "كلمة المرور", + "account_upgrade_dialog_title": "تغيير فئة الحساب", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية", + "account_upgrade_dialog_button_cancel": "إلغاء", + "account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك", + "account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك", + "account_tokens_title": "رموز الوصول", + "account_tokens_table_token_header": "الرمز المميز", + "account_tokens_table_last_access_header": "آخر وصول", + "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", + "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", + "account_tokens_table_current_session": "جلسة المتصفح الحالية", + "common_copy_to_clipboard": "انسخ إلى الحافظة", + "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية", + "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول", + "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", + "account_tokens_dialog_title_create": "إنشاء رمز مميز للوصول", + "account_tokens_dialog_title_edit": "تعديل الرمز المميز للوصول", + "account_tokens_dialog_title_delete": "حذف الرمز المميز للوصول", + "account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار", + "account_tokens_dialog_button_create": "إنشاء رمز مميز", + "account_tokens_dialog_button_update": "تحديث الرمز المميز", + "account_tokens_dialog_button_cancel": "إلغاء", + "account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في", + "account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير", + "account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات", + "account_tokens_dialog_expires_never": "لا تنتهي صلاحية الرمز المميز أبدًا", + "account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول", + "account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا", + "prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول", + "prefs_reservations_add_button": "إضافة موضوع محجوز", + "prefs_reservations_table": "جدول المواضيع المحجوزة", + "prefs_reservations_table_topic_header": "الموضوع", + "prefs_reservations_table_access_header": "الوصول", + "prefs_reservations_table_everyone_deny_all": "أنا فقط من يستطيع النشر والاشتراك", + "prefs_reservations_table_everyone_write_only": "يمكنني النشر والاشتراك ، ويمكن للجميع النشر", + "prefs_reservations_table_everyone_read_write": "يمكن للجميع النشر والاشتراك", + "prefs_reservations_table_not_subscribed": "غير مشترك", + "prefs_reservations_dialog_title_edit": "تحرير الموضوع المحجوز", + "prefs_reservations_dialog_topic_label": "الموضوع", + "prefs_reservations_dialog_access_label": "الوصول", + "reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا", + "reservation_delete_dialog_submit_button": "حذف الحجز", + "signup_title": "إنشاء حساب ntfy", + "common_cancel": "إلغاء", + "signup_form_password": "كلمة المرور", + "signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!", + "signup_form_button_submit": "إنشاء حساب", + "signup_disabled": "تم تعطيل التسجيل", + "display_name_dialog_placeholder": "الإسم المعروض", + "display_name_dialog_title": "تغيير الإسم المعروض", + "account_basics_tier_basic": "أساسي", + "account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة", + "account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب", + "account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة", + "account_delete_dialog_button_cancel": "إلغاء", + "account_delete_dialog_button_submit": "حذف الحساب نهائيا", + "account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك", + "account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول", + "prefs_reservations_title": "المواضيع المحجوزة", + "prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك", + "prefs_reservations_table_click_to_subscribe": "انقر للاشتراك", + "reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا", + "action_bar_reservation_delete": "إزالة الحجز", + "display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.", + "prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.", + "notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على موقع الويب أو على الدليل.", + "publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى الدليل.", + "subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".", + "prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها", + "notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.", + "priority_low": "منخفضة", + "signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور", + "account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)", + "account_tokens_table_label_header": "المُلصَقة", + "account_upgrade_dialog_button_redirect_signup": "تسجيل فوري", + "account_upgrade_dialog_tier_current_label": "الحالي", + "account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام", + "prefs_reservations_dialog_title_add": "حجز موضوع", + "prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.", + "prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.", + "reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.", + "notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}", + "notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.", + "error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا.
إن كان لديك دقيقة، يرجى الإبلاغ عن ذلك على GitHub ، أو إعلامنا عبر Discord أو Matrix .", + "nav_button_muted": "الإشعارات المكتومة", + "priority_min": "دنيا", + "signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ", + "action_bar_reservation_limit_reached": "بلغت الحد الأقصى", + "prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع", + "prefs_reservations_edit_button": "تعديل الوصول إلى موضوع", + "prefs_reservations_limit_reached": "لقد بلغت الحد الأقصى من المواضيع المحجوزة.", + "reservation_delete_dialog_action_keep_description": "ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.", + "reservation_delete_dialog_description": "تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.", + "prefs_reservations_dialog_description": "يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "توفير ما يصل إلى {{discount}}٪", + "account_upgrade_dialog_interval_monthly": "شهريا", + "account_upgrade_dialog_tier_features_attachment_total_size": "إجمالي مساحة التخزين {{totalsize}}", + "publish_dialog_progress_uploading_detail": "تحميل {{loaded}}/{{total}} ({{percent}}٪) …", + "account_basics_tier_interval_monthly": "شهريا", + "account_basics_tier_interval_yearly": "سنويا", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} مواضيع محجوزة", + "account_upgrade_dialog_billing_contact_website": "للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى موقعنا على الويب.", + "prefs_notifications_min_priority_description_x_or_higher": "إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى", + "account_upgrade_dialog_billing_contact_email": "للأسئلة المتعلقة بالفوترة، الرجاء الاتصال بنا مباشرة.", + "account_upgrade_dialog_tier_selected_label": "المحدد", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} لكل ملف", + "account_upgrade_dialog_interval_yearly": "سنويا", + "account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة", + "account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪", + "publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر", + "prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)", + "publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}", + "publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية", + "account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا", + "account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.", + "account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن.", + "nav_upgrade_banner_description": "حجز المواضيع والمزيد من الرسائل ورسائل البريد الإلكتروني والمرفقات الأكبر حجمًا" +} diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index edfb8184..c4cdb102 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -1,9 +1,9 @@ { "action_bar_clear_notifications": "Премахване на известия", - "alert_grant_description": "Разрешете на мрежовия четец да показва известия.", + "alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.", "notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл", "notifications_example": "Пример", - "notifications_no_subscriptions_title": "Липсват абонаменти", + "notifications_no_subscriptions_title": "Липсват абонаменти.", "nav_topics_title": "Абонаменти", "action_bar_send_test_notification": "Пробно известие", "action_bar_unsubscribe": "Отписване", @@ -14,7 +14,7 @@ "publish_dialog_progress_uploading": "Изпращане…", "publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…", "publish_dialog_message_published": "Известието е публикувано", - "publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението и квотата от {{fileSizeLimit}}, оставащи {{remainingBytes}}", + "publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл и квотата, остават {{remainingBytes}}", "publish_dialog_message_label": "Съобщение", "publish_dialog_message_placeholder": "Въведете съобщение", "publish_dialog_other_features": "Други възможности:", @@ -30,12 +30,12 @@ "prefs_notifications_title": "Известия", "prefs_notifications_sound_title": "Звук при получаване", "prefs_notifications_sound_no_sound": "Без звук", - "prefs_notifications_min_priority_title": "Минимален приоритет", + "prefs_notifications_min_priority_title": "Най-нисък приоритет", "prefs_notifications_min_priority_any": "Всички", "prefs_notifications_min_priority_low_and_higher": "Нисък приоритет и по-висок", "prefs_notifications_min_priority_default_and_higher": "Подразбиран приоритет и по-висок", "prefs_notifications_min_priority_high_and_higher": "Висок приоритет и по-висок", - "prefs_notifications_min_priority_max_only": "Само максимален приоритет", + "prefs_notifications_min_priority_max_only": "Само най-висок приоритет", "prefs_notifications_delete_after_never": "Никога", "prefs_users_add_button": "Добавяне", "prefs_users_dialog_password_label": "Парола", @@ -43,12 +43,12 @@ "message_bar_type_message": "Въведете съобщение", "message_bar_error_publishing": "Грешка при изпращане на известието", "notifications_copied_to_clipboard": "Копирано в междинната памет", - "notifications_attachment_link_expired": "препратката за изтегляне е невалидна", + "notifications_attachment_link_expired": "препратката за изтегляне е с изтекла давност", "nav_button_settings": "Настройки", "nav_button_documentation": "Ръководство", "nav_button_subscribe": "Абониране за тема", - "alert_grant_title": "Известията са изключени", - "alert_grant_button": "Разрешаване", + "alert_notification_permission_required_title": "Известията са изключени", + "alert_notification_permission_required_button": "Разрешаване", "notifications_tags": "Етикети", "nav_button_publish_message": "Изпращане", "alert_not_supported_title": "Не се поддържат известия", @@ -59,27 +59,27 @@ "notifications_actions_open_url_title": "Към {{url}}", "notifications_click_copy_url_button": "Копиране на препратка", "notifications_click_open_button": "Отваряне", - "notifications_click_copy_url_title": "Копира препратката в междинната памет", - "notifications_none_for_topic_title": "Липсват известия в темата", - "notifications_none_for_any_title": "Липсват известия", - "notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто изпратете PUT или POST към адреса ѝ.", - "notifications_none_for_any_description": "За да изпратите известия в тема, просто изпратете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.", - "notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получавате тук.", + "notifications_click_copy_url_title": "Копиране на препратката в междинната памет", + "notifications_none_for_topic_title": "Липсват известия в темата.", + "notifications_none_for_any_title": "Липсват известия.", + "notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса й.", + "notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.", + "notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.", "notifications_more_details": "За допълнителна информация посетете страницата или документацията.", - "publish_dialog_priority_min": "Мин. приоритет", - "publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}}", + "publish_dialog_priority_min": "Най-нисък приоритет", + "publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл", "publish_dialog_base_url_label": "Адрес на услугата", "publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com", "publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts", "publish_dialog_priority_low": "Нисък приоритет", - "publish_dialog_attachment_limits_quota_reached": "надвишава ограничението, оставащи {{remainingBytes}}", + "publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}", "publish_dialog_priority_high": "Висок приоритет", "publish_dialog_priority_default": "Подразбиран приоритет", "publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска", "publish_dialog_tags_label": "Етикети", "publish_dialog_email_label": "Адрес на електронна поща", - "publish_dialog_priority_max": "Макс. приоритет", - "publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. внимание, диск", + "publish_dialog_priority_max": "Най-висок приоритет", + "publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. warning, srv1-backup", "publish_dialog_click_label": "Адрес", "publish_dialog_topic_label": "Име на темата", "publish_dialog_title_label": "Заглавие", @@ -98,13 +98,13 @@ "publish_dialog_attached_file_title": "Прикачен файл:", "publish_dialog_attached_file_filename_placeholder": "Име на прикачения файл", "publish_dialog_drop_file_here": "Пуснете файла тук", - "subscribe_dialog_subscribe_description": "Възможно е темите да не са защитени с парола, затова изберете име, което е трудно за отгатване. След като се абонирате, можете да изпращате известия по PUT или POST.", + "subscribe_dialog_subscribe_description": "Възможно е темите да не са защитени с парола, затова изберете име, което е трудно за отгатване. След като се абонирате, можете да изпращате известия чрез методите PUT или POST.", "emoji_picker_search_placeholder": "Търсете емоция", "subscribe_dialog_subscribe_title": "Абониране за тема", "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_login_username_label": "Потребител, напр. phil", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_subscribe_button_subscribe": "Абониране", @@ -114,8 +114,8 @@ "prefs_users_table_user_header": "Потребител", "prefs_users_dialog_title_edit": "Промяна на потребител", "prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh", - "prefs_users_dialog_button_cancel": "Отказ", - "prefs_users_dialog_button_save": "Запазване", + "common_cancel": "Отказ", + "common_save": "Запазване", "prefs_appearance_language_title": "Език", "subscribe_dialog_login_password_label": "Парола", "subscribe_dialog_login_button_login": "Вход", @@ -128,29 +128,210 @@ "prefs_users_dialog_title_add": "Добавяне на потребител", "prefs_notifications_delete_after_one_month": "След един месец", "prefs_users_dialog_username_label": "Потребител, напр. phil", - "prefs_users_dialog_button_add": "Добавяне", + "common_add": "Добавяне", "error_boundary_title": "О, не, ntfy се срина", - "error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!
Ако имате минута, докладвайте в GitHub, или ни уведомете в Discord или Matrix.", + "error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!
Ако имате минута, докладвайте в GitHub или ни уведомете в Discord или Matrix.", "error_boundary_stack_trace": "Следа от стека", "error_boundary_gathering_info": "Събиране на допълнителна информация…", "notifications_loading": "Зареждане на известия…", "error_boundary_button_copy_stack_trace": "Копиране на следата от стека", "prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.", "prefs_notifications_sound_description_none": "Известията не са съпроводени със звук", - "prefs_notifications_sound_description_some": "Известията са съпроводени със звука „{{sound}}“", + "prefs_notifications_sound_description_some": "При пристигане известията са съпроводени от звука „{{sound}}“", "prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично", "prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа", - "priority_min": "минимален", + "priority_min": "най-нисък", "priority_low": "нисък", "priority_high": "висок", - "priority_max": "максимален", + "priority_max": "най-висок", "priority_default": "подразбиран", "prefs_notifications_delete_after_one_week_description": "Известията се премахват автоматично след една седмица", "prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден", "prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)", "prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец", - "prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им", + "prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета", "prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок", "notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}", - "notifications_actions_not_supported": "Действието не се поддържа от приложението за уеб" + "notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет", + "action_bar_show_menu": "Показване на менюто", + "action_bar_logo_alt": "Логотип на ntfy", + "action_bar_toggle_mute": "Заглушаване или пускне на известията", + "action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията", + "nav_button_muted": "Известията са заглушени", + "notifications_list": "Списък с известия", + "notifications_list_item": "Известие", + "notifications_delete": "Премахване", + "notifications_mark_read": "Отбелязване като прочетено", + "nav_button_connecting": "свързване", + "message_bar_show_dialog": "Показване на диалога за публикуване", + "message_bar_publish": "Публикуване на съобщение", + "notifications_priority_x": "Приоритет {{priority}}", + "notifications_new_indicator": "Ново известие", + "notifications_attachment_image": "Прикачено изображение", + "notifications_attachment_file_image": "файл на изображение", + "notifications_attachment_file_video": "видео", + "notifications_attachment_file_audio": "аудио", + "notifications_attachment_file_app": "инсталационен файл на приложение за Android", + "notifications_attachment_file_document": "друг документ", + "publish_dialog_emoji_picker_show": "Избор на емоция", + "publish_dialog_topic_reset": "Нулиране на тема", + "publish_dialog_click_reset": "Премахване на адрес", + "publish_dialog_email_reset": "Премахване на препращането към ел. поща", + "publish_dialog_delay_reset": "Премахва забавянето на изпращането", + "publish_dialog_attached_file_remove": "Премахване на прикачения файл", + "emoji_picker_search_clear": "Изчистване на търсенето", + "subscribe_dialog_subscribe_base_url_label": "Адрес на услугата", + "prefs_notifications_sound_play": "Възпроизвеждане на избрания звук", + "publish_dialog_attach_reset": "Премахване на адреса на файла за прикачане", + "prefs_users_delete_button": "Премахване", + "prefs_users_table": "Таблица с потребители", + "prefs_users_edit_button": "Промяна на потребител", + "error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа", + "error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.

Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по проблема в GitHub или да се свържете с нас в Discord или Matrix.", + "signup_title": "Създаване на профил в ntfy", + "signup_form_username": "Потребител", + "signup_form_password": "Парола", + "signup_form_button_submit": "Регистриране", + "signup_form_toggle_password_visibility": "Превключване видимостта на паролата", + "signup_already_have_account": "Имате профил? Впишете се!", + "signup_error_username_taken": "Потребителското име {{username}} е заето", + "login_title": "Впишете се в профила си в ntfy", + "login_form_button_submit": "Вписване", + "login_link_signup": "Регистриране", + "login_disabled": "Вписването е изключено", + "action_bar_account": "Профил", + "action_bar_change_display_name": "Промяна на показваното име", + "action_bar_reservation_add": "Резервиране на тема", + "action_bar_reservation_delete": "Премахване на резервацията", + "action_bar_reservation_limit_reached": "Ограничението е достигнато", + "action_bar_profile_title": "Профил", + "action_bar_profile_settings": "Настройки", + "action_bar_profile_logout": "Изход", + "action_bar_sign_in": "Вписване", + "nav_button_account": "Профил", + "nav_upgrade_banner_label": "Надграждане до ntfy Pro", + "signup_form_confirm_password": "Парола отново", + "signup_disabled": "Регистрациите са затворени", + "signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили", + "display_name_dialog_title": "Промяна на показваното име", + "action_bar_reservation_edit": "Промяна на резервацията", + "action_bar_sign_up": "Регистриране", + "account_basics_title": "Профил", + "alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на Notifications API.", + "display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.", + "subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана", + "nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове", + "display_name_dialog_placeholder": "Наименование", + "reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп", + "subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име", + "account_basics_username_title": "Потребител", + "account_basics_username_description": "Хей, това сте вие ❤", + "account_basics_username_admin_tooltip": "Вие сте администратор", + "account_basics_password_title": "Парола", + "account_delete_dialog_label": "Парола", + "account_basics_password_dialog_title": "Смяна на парола", + "account_basics_password_dialog_current_password_label": "Текуща парола", + "account_basics_password_dialog_new_password_label": "Нова парола", + "account_basics_password_dialog_confirm_password_label": "Парола отново", + "account_basics_password_dialog_button_submit": "Смяна на парола", + "account_usage_title": "Употреба", + "account_usage_of_limit": "от {{limit}}", + "account_usage_unlimited": "Неограничено", + "account_usage_limits_reset_daily": "Ограниченията се нулират всеки ден в полунощ (UTC)", + "account_basics_tier_interval_monthly": "месечно", + "account_basics_tier_interval_yearly": "годишно", + "account_basics_password_description": "Промяна на паролата на профила", + "account_basics_tier_title": "Вид на профила", + "account_basics_tier_admin": "Администратор", + "account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)", + "account_basics_tier_admin_suffix_no_tier": "(без ниво)", + "account_basics_tier_free": "безплатен", + "account_basics_tier_basic": "базов", + "account_basics_tier_change_button": "Променяне", + "account_basics_tier_paid_until": "Абонаментът е платен до {{date}} и автоматично ще се поднови", + "account_usage_attachment_storage_title": "Хранилище за прикачени файлове", + "account_delete_dialog_button_cancel": "Отказ", + "account_upgrade_dialog_interval_monthly": "Месечно", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} резервирани теми", + "account_upgrade_dialog_tier_features_no_reservations": "Няма резервирани теми", + "account_tokens_dialog_button_cancel": "Отказ", + "account_delete_title": "Премахване на профила", + "account_upgrade_dialog_title": "Промяна нивото на профила", + "account_usage_emails_title": "Изпратени съобщения", + "account_usage_reservations_title": "Резервирани теми", + "account_usage_reservations_none": "Няма резервирани теми", + "account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен", + "account_upgrade_dialog_interval_yearly": "Годишно", + "account_delete_description": "Безвъзвратно премахване на профила", + "account_delete_dialog_button_submit": "Безвъзвратно премахване на профила", + "account_upgrade_dialog_interval_yearly_discount_save": "отстъпка {{discount}}%", + "account_upgrade_dialog_button_cancel": "Отказ", + "account_upgrade_dialog_button_redirect_signup": "Регистриране", + "account_tokens_table_label_header": "Етикет", + "prefs_reservations_edit_button": "Настройки на достъпа", + "prefs_reservations_table_topic_header": "Тема", + "prefs_reservations_table_access_header": "Достъп", + "prefs_reservations_dialog_topic_label": "Тема", + "prefs_reservations_dialog_access_label": "Достъп", + "account_basics_password_dialog_current_password_incorrect": "Грешна парола", + "account_basics_tier_description": "Ниво на профила", + "account_basics_tier_upgrade_button": "Надграждане до Pro", + "account_usage_messages_title": "Публикувани съобщения", + "account_tokens_table_last_access_header": "Последен достъп", + "account_basics_tier_payment_overdue": "Имате просрочено задължение. Обновете начина на плащане, защото в противен случай скоро профилът ви ще загуби предимствата на абонамента.", + "account_usage_basis_ip_description": "Статистиката и ограниченията на използване се отчитат по IP адрес, така че може да бъдат споделени с други потребители. Показаните по-горе ограничения са приблизителни и се основават на съществуващите ограничения на използване.", + "account_delete_dialog_description": "Това действие ще доведе до безвъзвратното изтриване на профила ви, включително на всички данни, които се съхраняват на сървъра. След изтриването потребителското ви име няма да бъде достъпно в продължение на 7 дни. Ако наистина искате да продължите, потвърдете с паролата си в полето по-долу.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} резервирана тема", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "спестете до {{discount}}%", + "account_delete_dialog_billing_warning": "Изтриването на профила незабавно отменя и платения абонамент. Няма да имате достъп до таблото за плащания.", + "account_upgrade_dialog_cancel_warning": "Това действие ще прекрати абонамента и ще промени профила ви на неплатен на {{date}}. На тази дата резервираните теми, както и пазените на сървъра съобщения, ще бъдат премахнати.", + "account_upgrade_dialog_proration_info": "Преизчисляване на плащания: При надграждане между платени планове разликата в цената ще бъде начислена незабавно. При преминаване към по-евтин план надплатената сума ще бъде използвана за плащане за бъдещи периоди.", + "account_basics_tier_manage_billing_button": "Управление на плащанията", + "account_basics_tier_canceled_subscription": "Абонаментът е прекратен и профилът ще бъде променен на неплатен на {{date}}.", + "account_basics_phone_numbers_dialog_verify_button_sms": "Изпращане на SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Обаждане до мен", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} телефонни обаждания на ден", + "common_copy_to_clipboard": "Копиране в междинната памет", + "publish_dialog_call_label": "Телефонно обаждане", + "publish_dialog_call_reset": "Премахване на телефонно обаждане", + "publish_dialog_chip_call_label": "Телефонно обаждане", + "account_basics_phone_numbers_dialog_description": "За да възползвате от услугата известяване чрез телефонно обаждане, трябва да добавите и потвърдите поне един телефонен номер. Проверката може да бъде извършена чрез SMS или телефонно обаждане.", + "account_basics_phone_numbers_title": "Телефонни номера", + "account_basics_phone_numbers_dialog_number_placeholder": "напр. +1222333444", + "account_basics_phone_numbers_dialog_number_label": "Телефонен номер", + "account_basics_phone_numbers_dialog_title": "Добавяне на телефонен номер", + "account_basics_phone_numbers_copied_to_clipboard": "Телефонният номер е копиран в междинната памет", + "account_basics_phone_numbers_no_phone_numbers_yet": "Все още няма телефонни номера", + "account_basics_phone_numbers_description": "За известяване чрез телефонно обаждане", + "publish_dialog_call_item": "Обаждане на телефонен номер {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Няма потвърдени телефонни номера", + "account_basics_phone_numbers_dialog_channel_call": "Обаждане", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_check_verification_button": "Код за потвърждаване", + "account_basics_phone_numbers_dialog_code_placeholder": "напр. 123456", + "account_basics_phone_numbers_dialog_code_label": "Код за потвърждение", + "account_usage_calls_none": "С този профил не могат да се извършват телефонни обаждания", + "account_usage_calls_title": "Извършени телефонни обаждания", + "account_upgrade_dialog_tier_features_no_calls": "Без телефонни обаждания", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} съобщение на ден", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} съобщения на ден", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} ел. писмо на ден", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} ел. писма на ден", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонни обаждания на ден", + "account_usage_attachment_storage_description": "{{filesize}} на файл, изтриване след {{expiry}}", + "account_upgrade_dialog_billing_contact_email": "За въпроси относно плащанията се свържете с нас.", + "account_upgrade_dialog_tier_current_label": "Текущо", + "account_upgrade_dialog_billing_contact_website": "За въпроси относно плащанията се обърнете към страницата.", + "account_upgrade_dialog_button_cancel_subscription": "Прекратяване на абонамент", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", + "account_upgrade_dialog_reservations_warning_one": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото изтрийте най-малко една резервирана тема. Можете да премахвате теми в Настройки.", + "account_tokens_title": "Кодове за достъп", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на година. Плаща се всеки месец.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} плащане на година. Спестявате {{save}}.", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} общ обем", + "account_upgrade_dialog_tier_price_per_month": "на месец", + "account_upgrade_dialog_button_pay_now": "Плащане и абониране", + "account_upgrade_dialog_tier_selected_label": "Избрано", + "account_upgrade_dialog_button_update_subscription": "Премяна на абонамент", + "account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото изтрийте най-малко {{count}} резервирани теми. Можете да премахвате теми в Настройки." } diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index f8a40c48..cd1f851b 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -11,9 +11,9 @@ "nav_button_documentation": "Dokumentace", "nav_button_publish_message": "Odeslat oznámení", "nav_button_subscribe": "Přihlásit se k odběru tématu", - "alert_grant_title": "Oznámení jsou zakázána", - "alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.", - "alert_grant_button": "Udělit nyní", + "alert_notification_permission_required_title": "Oznámení jsou zakázána", + "alert_notification_permission_required_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.", + "alert_notification_permission_required_button": "Udělit nyní", "alert_not_supported_title": "Oznámení nejsou podporována", "alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.", "notifications_copied_to_clipboard": "Zkopírováno do schránky", @@ -91,7 +91,7 @@ "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "subscribe_dialog_login_password_label": "Heslo", - "subscribe_dialog_login_button_back": "Zpět", + "common_back": "Zpět", "subscribe_dialog_login_button_login": "Přihlásit se", "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_anonymous": "anonymně", @@ -116,9 +116,9 @@ "prefs_users_add_button": "Přidat uživatele", "prefs_users_table_user_header": "Uživatel", "prefs_users_table_base_url_header": "URL služby", - "prefs_users_dialog_button_cancel": "Zrušit", - "prefs_users_dialog_button_add": "Přidat", - "prefs_users_dialog_button_save": "Uložit", + "common_cancel": "Zrušit", + "common_add": "Přidat", + "common_save": "Uložit", "priority_min": "nejnižší", "priority_low": "nízká", "priority_default": "výchozí", @@ -152,5 +152,233 @@ "prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.", "error_boundary_gathering_info": "Získejte více informací …", "prefs_appearance_language_title": "Jazyk", - "prefs_appearance_title": "Vzhled" + "prefs_appearance_title": "Vzhled", + "action_bar_show_menu": "Zobrazit nabídku", + "action_bar_logo_alt": "logo ntfy", + "action_bar_toggle_mute": "Ztlumení/zrušení ztlumení oznámení", + "action_bar_toggle_action_menu": "Otevřít/zavřít nabídku akcí", + "message_bar_show_dialog": "Zobrazit okno pro odesílání oznámení", + "message_bar_publish": "Odeslat zprávu", + "nav_button_muted": "Oznámení ztlumena", + "nav_button_connecting": "připojování", + "notifications_list": "Seznam oznámení", + "notifications_list_item": "Oznámení", + "notifications_mark_read": "Označit jako přečtené", + "notifications_delete": "Smazat", + "notifications_new_indicator": "Nové oznámení", + "notifications_attachment_image": "Obrázek přílohy", + "notifications_attachment_file_image": "soubor s obrázkem", + "notifications_attachment_file_video": "video soubor", + "notifications_attachment_file_audio": "zvukový soubor", + "notifications_attachment_file_app": "Soubor s aplikací pro Android", + "publish_dialog_emoji_picker_show": "Vybrat emoji", + "publish_dialog_topic_reset": "Obnovení tématu", + "publish_dialog_click_reset": "Odebrat URL kliknutím", + "publish_dialog_email_reset": "Odebrat přeposlání e-mailu", + "publish_dialog_attach_reset": "Odebrat URL přílohy", + "publish_dialog_attached_file_remove": "Odebrat přiložený soubor", + "emoji_picker_search_clear": "Vyčistit vyhledávání", + "prefs_users_edit_button": "Upravit uživatele", + "prefs_users_delete_button": "Odstranit uživatele", + "error_boundary_unsupported_indexeddb_title": "Soukromé prohlížení není podporováno", + "error_boundary_unsupported_indexeddb_description": "Webová aplikace ntfy potřebuje ke svému fungování databázi IndexedDB a váš prohlížeč v režimu soukromého prohlížení databázi IndexedDB nepodporuje.

To je sice nepříjemné, ale používat webovou aplikaci ntfy v režimu soukromého prohlížení stejně nemá smysl, protože vše je uloženo v úložišti prohlížeče. Více se o tom můžete dočíst v tomto tématu na GitHubu, nebo se na nás obrátit pomocí služeb Discord nebo Matrix.", + "notifications_priority_x": "Priorita {{priority}}", + "subscribe_dialog_subscribe_base_url_label": "URL služby", + "prefs_notifications_sound_play": "Přehrát vybraný zvuk", + "prefs_users_table": "Tabulka uživatelů", + "notifications_attachment_file_document": "jiný dokument", + "publish_dialog_delay_reset": "Odebrat odložené doručení", + "signup_form_confirm_password": "Potvrdit heslo", + "signup_form_button_submit": "Zaregistrovat se", + "signup_form_username": "Uživatelské jméno", + "signup_form_toggle_password_visibility": "Přepnout viditelnost hesla", + "signup_already_have_account": "Už máte účet? Přihlašte se!", + "signup_error_username_taken": "Uživatelské jméno {{username}} je již obsazeno", + "signup_error_creation_limit_reached": "Dosažen limit pro vytvoření účtu", + "login_title": "Přihlaste se do svého ntfy účtu", + "login_form_button_submit": "Přihlásit se", + "login_link_signup": "Zaregistrovat se", + "login_disabled": "Přihlašování je zakázáno", + "action_bar_account": "Účet", + "action_bar_reservation_add": "Rezervovat téma", + "action_bar_reservation_edit": "Změnit rezervaci", + "action_bar_reservation_delete": "Odstranit rezervaci", + "action_bar_reservation_limit_reached": "Limit dosažen", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Nastavení", + "action_bar_profile_logout": "Odhlásit se", + "action_bar_sign_up": "Zaregistrovat se", + "nav_button_account": "Účet", + "nav_upgrade_banner_label": "Upgradovat na nfty Pro", + "nav_upgrade_banner_description": "Rezervace témat, více zpráv a emailů a větší přílohy", + "signup_title": "Vytvořit nfty účet", + "signup_form_password": "Heslo", + "display_name_dialog_description": "Nastaví alternativní název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.", + "action_bar_change_display_name": "Změnit zobrazovaný název", + "action_bar_sign_in": "Přihlásit se", + "alert_not_supported_context_description": "Oznámení jsou podporována pouze přes HTTPS. Toto je limitace Notifications API.", + "display_name_dialog_title": "Změnit zobrazovaný název", + "account_basics_password_title": "Heslo", + "account_basics_password_dialog_title": "Změna hesla", + "subscribe_dialog_error_topic_already_reserved": "Téma již rezervováno", + "subscribe_dialog_subscribe_button_generate_topic_name": "Generovat název", + "account_delete_dialog_description": "Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.", + "account_basics_tier_admin_suffix_with_tier": "(s úrovní {{tier}})", + "account_basics_tier_admin": "Administrátor", + "account_basics_tier_basic": "Základní", + "account_basics_tier_free": "Zdarma", + "account_basics_tier_admin_suffix_no_tier": "(žádná úroveň)", + "account_basics_tier_upgrade_button": "Přejít na verzi Pro", + "account_upgrade_dialog_cancel_warning": "Vaše předplatné se tímto zruší a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou smazány rezervace témat i zprávy uložené v mezipaměti serveru.", + "account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně odstraňte alespoň {{počet}} rezervací. Rezervace můžete odstranit v Nastavení.", + "reservation_delete_dialog_description": "Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.", + "account_tokens_description": "Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v dokumentaci.", + "account_tokens_table_copied_to_clipboard": "Přístupový token zkopírován", + "account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, klikněte pro vyhledání", + "account_tokens_dialog_button_cancel": "Zrušit", + "account_tokens_dialog_expires_never": "Token nikdy nevyprší", + "account_tokens_delete_dialog_description": "Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. Tuto akci nelze vrátit zpět.", + "prefs_users_description_no_sync": "Uživatelé a hesla nejsou synchronizováni s vaším účtem.", + "prefs_users_table_cannot_delete_or_edit": "Nelze odstranit ani upravit přihlášeného uživatele", + "prefs_reservations_title": "Rezervovaná témata", + "prefs_reservations_description": "Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.", + "prefs_reservations_table_click_to_subscribe": "Kliknutím se přihlásíte k odběru", + "prefs_reservations_dialog_description": "Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.", + "prefs_reservations_dialog_access_label": "Přístup", + "reservation_delete_dialog_action_keep_title": "Zachovat zprávy a přílohy v mezipaměti", + "signup_disabled": "Přihlášení je zakázáno", + "display_name_dialog_placeholder": "Zobrazovaný název", + "reserve_dialog_checkbox_label": "Rezervace tématu a nastavení přístupu", + "account_basics_title": "Účet", + "account_basics_username_title": "Uživatelské jméno", + "account_basics_username_description": "Hej, to jsi ty ❤", + "account_basics_username_admin_tooltip": "Jste správce", + "account_basics_password_description": "Změna hesla k účtu", + "account_basics_password_dialog_current_password_label": "Současné heslo", + "account_basics_password_dialog_new_password_label": "Nové heslo", + "account_basics_password_dialog_confirm_password_label": "Potvrzení hesla", + "account_basics_password_dialog_button_submit": "Změnit heslo", + "account_basics_password_dialog_current_password_incorrect": "Nesprávné heslo", + "account_usage_title": "Použití", + "account_usage_of_limit": "z {{limit}}", + "account_usage_unlimited": "Neomezeně", + "account_usage_limits_reset_daily": "Limity používání se resetují denně o půlnoci (UTC)", + "account_basics_tier_title": "Typ účtu", + "account_basics_tier_description": "Úroveň oprávnění vašeho účtu", + "account_basics_tier_change_button": "Změnit", + "account_basics_tier_paid_until": "Předplatné zaplaceno do {{date}} a bude automaticky obnoveno", + "account_basics_tier_payment_overdue": "Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.", + "account_basics_tier_canceled_subscription": "Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.", + "account_basics_tier_manage_billing_button": "Správa vyúčtování", + "account_usage_messages_title": "Zveřejněné zprávy", + "account_usage_emails_title": "Odeslané e-maily", + "account_usage_reservations_title": "Rezervovaná témata", + "account_usage_reservations_none": "Žádná rezervovaná témata pro tento účet", + "account_usage_attachment_storage_title": "Úložiště příloh", + "account_usage_attachment_storage_description": "{{filesize}} na soubor, maže se po {{expiry}}", + "account_usage_basis_ip_description": "Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.", + "account_usage_cannot_create_portal_session": "Nelze otevřít portál pro fakturaci", + "account_delete_title": "Odstranit účet", + "account_delete_description": "Trvale odstranit účet", + "account_delete_dialog_label": "Heslo", + "account_delete_dialog_button_cancel": "Zrušit", + "account_delete_dialog_button_submit": "Trvale odstranit účet", + "account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.", + "account_upgrade_dialog_title": "Změna úrovně účtu", + "account_upgrade_dialog_proration_info": "Prohlášení: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně zaúčtován okamžitě. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.", + "account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, odstraňte alespoň jednu rezervaci. Rezervace můžete odstranit v Nastavení.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} rezervovaných témat", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} denních zpráv", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} denních e-mailů", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor", + "account_upgrade_dialog_tier_selected_label": "Vybráno", + "account_upgrade_dialog_tier_current_label": "Současné", + "account_upgrade_dialog_button_cancel": "Zrušit", + "account_upgrade_dialog_button_redirect_signup": "Zaregistrovat se nyní", + "account_upgrade_dialog_button_pay_now": "Zaplatit a předplatit si", + "account_upgrade_dialog_button_cancel_subscription": "Zrušit předplatné", + "account_upgrade_dialog_button_update_subscription": "Aktualizovat předplatné", + "account_tokens_title": "Přístupové tokeny", + "account_tokens_table_token_header": "Token", + "account_tokens_table_last_access_header": "Poslední přístup", + "account_tokens_table_expires_header": "Vyprší", + "account_tokens_table_never_expires": "Nikdy nevyprší", + "account_tokens_table_current_session": "Současná relace prohlížeče", + "common_copy_to_clipboard": "Kopírování do schránky", + "account_tokens_table_label_header": "Popisek", + "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", + "account_tokens_table_create_token_button": "Vytvořit přístupový token", + "account_tokens_dialog_title_create": "Vytvoření přístupového tokenu", + "account_tokens_dialog_title_edit": "Úprava přístupového tokenu", + "account_tokens_dialog_title_delete": "Odstranění přístupového tokenu", + "account_tokens_dialog_label": "Popisek, např. Radarr notifications", + "account_tokens_dialog_button_create": "Vytvořit token", + "account_tokens_dialog_button_update": "Aktualizovat token", + "account_tokens_dialog_expires_label": "Platnost přístupového tokenu vyprší za", + "account_tokens_dialog_expires_unchanged": "Ponechat datum vypršení platnosti beze změny", + "account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodin", + "account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní", + "account_tokens_delete_dialog_title": "Odstranění přístupového tokenu", + "account_tokens_delete_dialog_submit_button": "Trvale odstranit token", + "prefs_reservations_limit_reached": "Dosáhli jste limitu rezervovaných témat.", + "prefs_reservations_add_button": "Přidat rezervované téma", + "prefs_reservations_edit_button": "Upravit přístup k tématu", + "prefs_reservations_delete_button": "Resetovat přístup k tématu", + "prefs_reservations_table": "Tabulka rezervovaných témat", + "prefs_reservations_table_topic_header": "Téma", + "prefs_reservations_table_access_header": "Přístup", + "prefs_reservations_table_everyone_deny_all": "Pouze já mohu publikovat a přihlásit se k odběru", + "prefs_reservations_table_everyone_read_only": "Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru", + "prefs_reservations_table_everyone_write_only": "Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat", + "prefs_reservations_table_everyone_read_write": "Kdokoli může publikovat a přihlásit se k odběru", + "prefs_reservations_table_not_subscribed": "Odběr není přihlášen", + "prefs_reservations_dialog_title_add": "Rezervovat téma", + "prefs_reservations_dialog_title_edit": "Úprava rezervovaného tématu", + "prefs_reservations_dialog_title_delete": "Odstranění rezervovaného tématu", + "prefs_reservations_dialog_topic_label": "Téma", + "reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.", + "reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti", + "reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.", + "reservation_delete_dialog_submit_button": "Odstranit rezervaci", + "account_basics_tier_interval_yearly": "roční", + "account_upgrade_dialog_interval_yearly_discount_save": "ušetříte {{discount}}%", + "account_upgrade_dialog_tier_price_per_month": "měsíc", + "account_upgrade_dialog_tier_features_no_reservations": "Žádná rezervovaná témata", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetříte až {{discount}}%", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtováno ročně. Ušetříte {{save}}.", + "account_basics_tier_interval_monthly": "měsíční", + "account_upgrade_dialog_interval_monthly": "Měsíční", + "account_upgrade_dialog_interval_yearly": "Roční", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje se měsíčně.", + "account_upgrade_dialog_billing_contact_email": "V případě dotazů týkajících se fakturace nás prosím kontaktujte přímo.", + "account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich webových stránkách.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail", + "publish_dialog_call_label": "Telefonát", + "publish_dialog_call_reset": "Odstranit telefonát", + "publish_dialog_chip_call_label": "Telefonát", + "account_basics_phone_numbers_title": "Telefonní čísla", + "account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.", + "account_basics_phone_numbers_description": "K oznámení telefonátem", + "account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla", + "publish_dialog_call_item": "Vytočit číslo {{number}}", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_title": "Přidat telefonní číslo", + "account_basics_phone_numbers_dialog_number_label": "Telefonní číslo", + "account_basics_phone_numbers_dialog_code_placeholder": "např. 123456", + "account_basics_phone_numbers_dialog_code_label": "Ověřovací kód", + "account_usage_calls_none": "S tímto účtem nelze uskutečňovat žádné telefonní hovory", + "account_basics_phone_numbers_dialog_check_verification_button": "Potvrdit kód", + "account_basics_phone_numbers_dialog_number_placeholder": "např. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Odeslat SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Zavolat mi", + "account_basics_phone_numbers_dialog_channel_call": "Zavolat", + "account_usage_calls_title": "Uskutečněné telefonáty", + "account_upgrade_dialog_tier_features_no_calls": "Žádné telefonní hovory", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} denní telefonní hovor", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} denních telefonních hovorů" } diff --git a/web/public/static/langs/cy.json b/web/public/static/langs/cy.json new file mode 100644 index 00000000..da6c9b41 --- /dev/null +++ b/web/public/static/langs/cy.json @@ -0,0 +1,48 @@ +{ + "notifications_delete": "Dileu", + "action_bar_sign_in": "Mewngofnodi", + "notifications_copied_to_clipboard": "Wedi'i gopio i'r clipfwrdd", + "common_cancel": "Canslo", + "nav_button_account": "Cyfrif", + "common_save": "Arbed", + "common_add": "Ychwanegu", + "signup_title": "Creu cyfrif ntfy", + "signup_form_username": "Enw defnyddiwr", + "signup_form_password": "Cyfrinair", + "action_bar_logo_alt": "logo ntfy", + "action_bar_settings": "Gosodiadau", + "action_bar_profile_title": "Proffil", + "action_bar_profile_logout": "Allgofnodi", + "message_bar_publish": "Cyhoeddi neges", + "notifications_attachment_copy_url_button": "Copio URL", + "notifications_attachment_open_title": "Ewch i {{url}}", + "publish_dialog_base_url_label": "URL y Gwasanaeth", + "publish_dialog_priority_high": "Blaenoriaeth uchel", + "publish_dialog_title_label": "Teitl", + "publish_dialog_message_label": "Neges", + "publish_dialog_attach_label": "URL Atodiad", + "publish_dialog_filename_label": "Enw ffeil", + "publish_dialog_filename_placeholder": "Enw ffeil yr atodiad", + "action_bar_account": "Cyfrif", + "action_bar_unsubscribe": "Dad-danysgrifio", + "login_title": "Mewngofnodi i'ch cyfrif ntfy", + "login_form_button_submit": "Mewngofnodi", + "action_bar_change_display_name": "Newid enw arddangos", + "action_bar_profile_settings": "Gosodiadau", + "nav_button_settings": "Gosodiadau", + "nav_button_documentation": "Dogfennaeth", + "alert_not_supported_context_description": "Dim ond dros HTTPS y gellir derbyn cyhoeddiadau. Mae hyn yn gyfyngiad ar yr API Notifications.", + "notifications_attachment_open_button": "Agor atodiad", + "notifications_attachment_file_document": "dogfen arall", + "notifications_click_open_button": "Agor linc", + "publish_dialog_base_url_placeholder": "URL y Gwasanaeth, e.e. https://example.com", + "publish_dialog_attach_placeholder": "Atodi ffeil drwy URL, e.e. https://f-droid.org/F-Droid.apk", + "notifications_click_copy_url_button": "Copio linc", + "notifications_actions_open_url_title": "Ewch i {{url}}", + "publish_dialog_email_label": "Ebost", + "signup_form_confirm_password": "Cadarnhau cyfrinair", + "signup_form_button_submit": "Cofrestru", + "common_back": "Yn ôl", + "common_copy_to_clipboard": "Copio i'r clipfwrdd", + "signup_already_have_account": "Gyda chyfrif yn barod? Mewngofnodi!" +} diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json new file mode 100644 index 00000000..21e7de76 --- /dev/null +++ b/web/public/static/langs/da.json @@ -0,0 +1,283 @@ +{ + "common_save": "Gem", + "common_add": "Tilføj", + "signup_title": "Opret en ntfy konto", + "signup_form_username": "Brugernavn", + "signup_form_password": "Kodeord", + "signup_form_confirm_password": "Bekræft kodeord", + "common_cancel": "Annuller", + "action_bar_account": "Konto", + "signup_error_username_taken": "Brugernavnet {{username}} er optaget", + "login_form_button_submit": "Log ind", + "action_bar_show_menu": "Vis menu", + "action_bar_logo_alt": "ntfy logo", + "action_bar_settings": "Indstillinger", + "signup_form_button_submit": "Opret konto", + "signup_form_toggle_password_visibility": "Skift synlighed af adgangskode", + "signup_disabled": "Tilmelding er deaktiveret", + "signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået", + "login_title": "Log ind på din ntfy konto", + "login_link_signup": "Opret konto", + "login_disabled": "Login er deaktiveret", + "action_bar_reservation_add": "Reserver emne", + "action_bar_reservation_edit": "Rediger reservation", + "action_bar_reservation_delete": "Fjern reservation", + "action_bar_reservation_limit_reached": "Grænsen er nået", + "action_bar_send_test_notification": "Send test notifikation", + "action_bar_unsubscribe": "Afmeld", + "action_bar_toggle_mute": "Slå lyden fra/til for notifikationer", + "action_bar_change_display_name": "Skift visningsnavn", + "action_bar_toggle_action_menu": "Åben/luk handlings menu", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Indstillinger", + "action_bar_profile_logout": "Log ud", + "action_bar_sign_in": "Log ind", + "action_bar_sign_up": "Opret konto", + "message_bar_type_message": "Skriv en besked her", + "nav_button_settings": "Indstillinger", + "message_bar_publish": "Offentliggør besked", + "nav_topics_title": "Tilmeldte emner", + "nav_button_all_notifications": "Alle notifikationer", + "nav_button_connecting": "forbinder", + "nav_upgrade_banner_label": "Opgrader til ntfy Pro", + "alert_notification_permission_required_title": "Notifikationer er deaktiveret", + "alert_notification_permission_required_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.", + "alert_not_supported_title": "Notifikationer understøttes ikke", + "alert_not_supported_description": "Notifikationer understøttes ikke i din browser.", + "alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i Notifications API.", + "nav_button_subscribe": "Abonner på emne", + "notifications_list_item": "Notifikation", + "notifications_delete": "Slet", + "notifications_tags": "Tags", + "notifications_list": "Notifikationsliste", + "notifications_mark_read": "Marker som læst", + "notifications_copied_to_clipboard": "Kopieret til udklipsholder", + "notifications_priority_x": "Prioritet {{priority}}", + "notifications_attachment_copy_url_title": "Kopier URL-adresse til vedhæftet fil til udklipsholder", + "notifications_attachment_copy_url_button": "Kopier URL", + "notifications_attachment_open_title": "Gå til {{url}}", + "notifications_attachment_open_button": "Åben vedhæftning", + "notifications_attachment_link_expires": "link udløber {{date}}", + "notifications_attachment_link_expired": "download link er udløbet", + "notifications_attachment_file_image": "billedfil", + "notifications_attachment_file_app": "Android app fil", + "notifications_attachment_file_document": "andet dokument", + "notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen", + "notifications_click_copy_url_button": "Kopier link", + "notifications_example": "Eksempel", + "notifications_click_open_button": "Åbn link", + "notifications_actions_not_supported": "Handlingen understøttes ikke i webappen", + "notifications_actions_http_request_title": "Send HTTP {{method}} til {{url}}", + "notifications_none_for_topic_title": "Du har ikke modtaget nogen notifikationer om dette emne endnu.", + "notifications_none_for_any_title": "Du har ikke modtaget nogen notifikationer.", + "display_name_dialog_placeholder": "Vist navn", + "publish_dialog_progress_uploading": "Uploader…", + "display_name_dialog_title": "Skift visningsnavn", + "publish_dialog_progress_uploading_detail": "Uploader {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_emoji_picker_show": "Vælg emoji", + "publish_dialog_priority_min": "Min. prioritet", + "publish_dialog_priority_low": "Lav prioritet", + "publish_dialog_priority_default": "Standardprioritet", + "publish_dialog_priority_high": "Høj prioritet", + "publish_dialog_title_label": "Titel", + "publish_dialog_message_label": "Besked", + "publish_dialog_tags_label": "Tags", + "publish_dialog_priority_label": "Prioritet", + "publish_dialog_message_placeholder": "Skriv en besked her", + "publish_dialog_tags_placeholder": "Komma-separeret liste over tags, f.eks. warning, srv1-backup", + "publish_dialog_click_label": "Klik på URL", + "publish_dialog_email_reset": "Fjern videresendelse af e-mail", + "publish_dialog_attach_placeholder": "Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk", + "publish_dialog_delay_label": "Forsinkelse", + "publish_dialog_button_send": "Send", + "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", + "common_back": "Tilbage", + "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", + "account_basics_title": "Konto", + "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", + "account_basics_username_admin_tooltip": "Du er Admin", + "account_basics_password_dialog_confirm_password_label": "Bekræft kodeord", + "account_basics_password_dialog_current_password_incorrect": "Forkert kodeord", + "account_usage_of_limit": "af {{limit}}", + "account_basics_tier_basic": "Grundlæggende", + "account_basics_tier_free": "Gratis", + "account_basics_tier_admin_suffix_no_tier": "(intet niveau)", + "account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)", + "account_usage_messages_title": "Offentliggjorte meddelelser", + "account_delete_dialog_button_submit": "Slet konto permanent", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil", + "account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu", + "account_tokens_table_expires_header": "Udløber", + "account_tokens_table_last_access_header": "Seneste adgang", + "account_tokens_delete_dialog_title": "Slet adgangstoken", + "prefs_notifications_sound_no_sound": "Ingen lyd", + "prefs_notifications_min_priority_title": "Minimumsprioritet", + "prefs_notifications_sound_play": "Afspil den valgte lyd", + "prefs_notifications_min_priority_max_only": "Kun maks. prioritet", + "prefs_notifications_delete_after_three_hours": "Efter tre timer", + "prefs_users_add_button": "Tilføj bruger", + "prefs_users_dialog_title_edit": "Rediger bruger", + "prefs_reservations_title": "Reserverede emner", + "prefs_reservations_add_button": "Tilføj reserveret emne", + "prefs_reservations_table_access_header": "Adgang", + "prefs_reservations_delete_button": "Nulstil emneadgang", + "prefs_reservations_dialog_title_edit": "Rediger reserveret emne", + "prefs_reservations_dialog_access_label": "Adgang", + "prefs_reservations_dialog_title_delete": "Slet emnereservation", + "priority_low": "lav", + "priority_min": "min", + "reservation_delete_dialog_submit_button": "Slet reservation", + "priority_high": "høj", + "priority_max": "maks", + "error_boundary_stack_trace": "Strack trace", + "error_boundary_button_copy_stack_trace": "Kopier stack trace", + "signup_already_have_account": "Har du allerede en konto? Log ind!", + "action_bar_clear_notifications": "Ryd alle notifikationer", + "notifications_new_indicator": "Ny notifikation", + "notifications_attachment_image": "Vedhæftet billede", + "account_delete_dialog_label": "Kodeord", + "error_boundary_unsupported_indexeddb_title": "Privat browsing understøttes ikke", + "notifications_actions_open_url_title": "Gå til {{url}}", + "notifications_attachment_file_audio": "lydfil", + "publish_dialog_click_placeholder": "URL der åbnes, når der klikkes på notifikationen", + "publish_dialog_email_placeholder": "Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com", + "notifications_attachment_file_video": "videofil", + "account_basics_tier_title": "Kontotype", + "publish_dialog_filename_label": "Filnavn", + "account_basics_tier_manage_billing_button": "Administrer fakturering", + "account_usage_emails_title": "Afsendte e-mails", + "account_usage_reservations_title": "Reserverede emner", + "account_delete_title": "Slet konto", + "nav_button_account": "Konto", + "nav_button_documentation": "Dokumentation", + "publish_dialog_priority_max": "Maks. prioritet", + "account_upgrade_dialog_button_cancel_subscription": "Opsig abonnement", + "account_upgrade_dialog_button_update_subscription": "Opdater abonnement", + "publish_dialog_button_cancel": "Annuller", + "publish_dialog_email_label": "Email", + "account_tokens_title": "Adgangstokens", + "account_tokens_table_never_expires": "Udløber aldrig", + "prefs_notifications_sound_title": "Notifikationslyd", + "account_tokens_dialog_button_update": "Opdater token", + "account_tokens_dialog_button_create": "Opret token", + "subscribe_dialog_subscribe_button_cancel": "Annuller", + "prefs_users_table_user_header": "Bruger", + "prefs_appearance_title": "Udseende", + "subscribe_dialog_login_button_login": "Log ind", + "subscribe_dialog_login_password_label": "Kodeord", + "subscribe_dialog_error_user_anonymous": "anonym", + "account_usage_title": "Anvendelse", + "account_basics_username_title": "Brugernavn", + "account_basics_tier_admin": "Admin", + "account_basics_password_title": "Kodeord", + "account_upgrade_dialog_tier_selected_label": "Valgt", + "account_usage_unlimited": "Ubegrænset", + "account_tokens_table_label_header": "Label", + "account_tokens_dialog_button_cancel": "Annuller", + "account_basics_tier_change_button": "Rediger", + "account_delete_dialog_button_cancel": "Annuller", + "account_upgrade_dialog_button_cancel": "Annuller", + "account_tokens_table_token_header": "Token", + "account_upgrade_dialog_tier_current_label": "Nuværende", + "prefs_notifications_title": "Notifikationer", + "prefs_notifications_delete_after_never": "Aldrig", + "prefs_reservations_table_topic_header": "Emne", + "prefs_users_dialog_password_label": "Kodeord", + "prefs_appearance_language_title": "Sprog", + "prefs_reservations_dialog_topic_label": "Emne", + "priority_default": "standard", + "publish_dialog_attached_file_remove": "Fjern vedhæftet fil", + "prefs_users_table": "Bruger tabel", + "prefs_users_edit_button": "Rediger bruger", + "prefs_users_dialog_title_add": "Tilføj bruger", + "prefs_users_delete_button": "Slet bruger", + "account_tokens_table_copied_to_clipboard": "Adgangstoken kopieret", + "prefs_notifications_min_priority_any": "Enhver prioritet", + "prefs_notifications_delete_after_title": "Slet notifikationer", + "publish_dialog_delay_reset": "Fjern forsinket levering", + "prefs_users_title": "Administrer brugere", + "account_basics_password_dialog_button_submit": "Skift kodeord", + "prefs_reservations_dialog_title_add": "Reserver emne", + "account_basics_password_dialog_current_password_label": "Nuværende kodeord", + "account_basics_password_dialog_new_password_label": "Nyt kodeord", + "notifications_loading": "Indlæser notifikationer…", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daglige e-mails", + "account_tokens_table_create_token_button": "Opret adgangstoken", + "account_tokens_dialog_title_delete": "Slet adgangstoken", + "publish_dialog_chip_email_label": "Videresend til e-mail", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} samlet lagerplads", + "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", + "account_basics_tier_upgrade_button": "Opgrader til Pro", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder", + "common_copy_to_clipboard": "Kopier til udklipsholder", + "prefs_reservations_edit_button": "Rediger emneadgang", + "account_upgrade_dialog_title": "Skift kontoniveau", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", + "account_tokens_dialog_expires_never": "Token udløber aldrig", + "account_tokens_table_current_session": "Nuværende browsersession", + "account_tokens_dialog_title_edit": "Rediger adgangstoken", + "account_tokens_dialog_title_create": "Opret adgangstoken", + "prefs_notifications_delete_after_one_day": "Efter en dag", + "account_tokens_delete_dialog_submit_button": "Slet token permanent", + "prefs_notifications_delete_after_one_month": "Efter en måned", + "prefs_notifications_delete_after_one_week": "Efter en uge", + "prefs_users_dialog_username_label": "Brugernavn, f.eks. phil", + "prefs_notifications_delete_after_one_day_description": "Notifikationer slettes automatisk efter en dag", + "notifications_none_for_topic_description": "For at sende en notifikation til dette emne, skal du blot sende en PUT eller POST til emne-URL'en.", + "notifications_none_for_any_description": "For at sende en notifikation til et emne, skal du blot sende en PUT eller POST til emne-URL'en. Her er et eksempel med et af dine emner.", + "notifications_no_subscriptions_title": "Det ser ud til, at du ikke har nogen abonnementer endnu.", + "notifications_more_details": "For mere information, se webstedet eller dokumentationen.", + "display_name_dialog_description": "Angiv et alternativt navn for et emne, der vises på abonnementslisten. Dette gør det nemmere at identificere emner med komplicerede navne.", + "reserve_dialog_checkbox_label": "Reserver emne og konfigurer adgang", + "publish_dialog_attachment_limits_file_reached": "overskrider {{fileSizeLimit}} filgrænse", + "publish_dialog_attachment_limits_quota_reached": "overskrider kvote, {{remainingBytes}} tilbage", + "publish_dialog_topic_label": "Emnenavn", + "publish_dialog_topic_placeholder": "Emnenavn, f.eks. phil_alerts", + "publish_dialog_topic_reset": "Nulstil emne", + "publish_dialog_click_reset": "Fjern klik-URL", + "publish_dialog_delay_placeholder": "Forsink levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (kun på engelsk)", + "publish_dialog_other_features": "Andre funktioner:", + "publish_dialog_chip_attach_url_label": "Vedhæft fil via URL", + "publish_dialog_chip_attach_file_label": "Vedhæft lokal fil", + "publish_dialog_details_examples_description": "For eksempler og en detaljeret beskrivelse af alle afsendelsesfunktioner henvises til dokumentationen.", + "publish_dialog_button_cancel_sending": "Annuller afsendelse", + "publish_dialog_attached_file_title": "Vedhæftet fil:", + "emoji_picker_search_placeholder": "Søg emoji", + "emoji_picker_search_clear": "Ryd søgning", + "subscribe_dialog_subscribe_title": "Abonner på emne", + "subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_alerts", + "subscribe_dialog_subscribe_button_generate_topic_name": "Generer navn", + "subscribe_dialog_login_title": "Login påkrævet", + "subscribe_dialog_login_description": "Dette emne er adgangskodebeskyttet. Indtast venligst brugernavn og adgangskode for at abonnere.", + "subscribe_dialog_error_user_not_authorized": "Brugeren {{username}} er ikke autoriseret", + "account_basics_password_description": "Skift adgangskoden til din konto", + "account_usage_limits_reset_daily": "Brugsgrænser nulstilles dagligt ved midnat (UTC)", + "account_basics_tier_paid_until": "Abonnementet er betalt indtil {{date}} og fornys automatisk", + "account_basics_tier_payment_overdue": "Din betaling er forfalden. Opdater venligst din betalingsmetode, ellers bliver din konto snart nedgraderet.", + "account_basics_tier_canceled_subscription": "Dit abonnement blev annulleret og vil blive nedgraderet til en gratis konto den {{date}}.", + "account_usage_cannot_create_portal_session": "Kan ikke åbne faktureringsportalen", + "account_delete_description": "Slet din konto permanent", + "account_delete_dialog_description": "Dette vil slette din konto permanent, inklusive alle data, der er gemt på serveren. Efter sletning vil dit brugernavn være utilgængeligt i 7 dage. Hvis du virkelig ønsker at fortsætte, bedes du bekræfte med dit kodeord i feltet nedenfor.", + "account_upgrade_dialog_button_pay_now": "Betal nu og abonner", + "account_tokens_table_last_origin_tooltip": "Fra IP-adresse {{ip}}, klik for at slå op", + "account_tokens_dialog_label": "Label, f.eks. radarmeddelelser", + "account_tokens_dialog_expires_label": "Adgangstoken udløber om", + "account_tokens_dialog_expires_unchanged": "Lad udløbsdatoen forblive uændret", + "account_tokens_dialog_expires_x_hours": "Token udløber om {{hours}} timer", + "account_tokens_dialog_expires_x_days": "Token udløber om {{days}} dage", + "prefs_notifications_sound_description_none": "Notifikationer afspiller ingen lyd, når de ankommer", + "prefs_notifications_sound_description_some": "Notifikationer afspiller {{sound}}-lyden, når de ankommer", + "prefs_notifications_min_priority_low_and_higher": "Lav prioritet og højere", + "prefs_notifications_min_priority_default_and_higher": "Standardprioritet og højere", + "prefs_notifications_min_priority_high_and_higher": "Høj prioritet og højere", + "prefs_notifications_delete_after_never_description": "Notifikationer slettes aldrig automatisk", + "prefs_notifications_delete_after_three_hours_description": "Notifikationer slettes automatisk efter tre timer", + "prefs_notifications_delete_after_one_week_description": "Notifikationer slettes automatisk efter en uge", + "prefs_notifications_delete_after_one_month_description": "Notifikationer slettes automatisk efter en måned", + "prefs_reservations_limit_reached": "Du har nået din grænse for reserverede emner.", + "prefs_reservations_table_click_to_subscribe": "Klik for at abonnere", + "reservation_delete_dialog_action_keep_title": "Behold cachelagrede meddelelser og vedhæftede filer", + "reservation_delete_dialog_action_delete_title": "Slet cachelagrede meddelelser og vedhæftede filer", + "error_boundary_title": "Oh nej, ntfy brød sammen", + "error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.
Hvis du har et øjeblik, bedes du rapportere dette på GitHub, eller give os besked via Discord eller Matrix." +} diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 9f3a077b..a43fb7da 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -5,7 +5,7 @@ "nav_button_documentation": "Dokumentation", "nav_button_publish_message": "Benachrichtigung senden", "nav_button_subscribe": "Thema abonnieren", - "alert_grant_title": "Benachrichtigungen sind deaktiviert", + "alert_notification_permission_required_title": "Benachrichtigungen sind deaktiviert", "publish_dialog_base_url_label": "Service-URL", "publish_dialog_details_examples_description": "Beispiele und ausführliche Informationen zu allen Optionen findest Du in der Dokumentation.", "publish_dialog_attached_file_filename_placeholder": "Dateiname des Anhangs", @@ -15,9 +15,9 @@ "prefs_notifications_min_priority_max_only": "Nur höchste Priorität", "prefs_notifications_delete_after_never": "Nie", "prefs_users_dialog_password_label": "Kennwort", - "prefs_users_dialog_button_cancel": "Abbrechen", - "prefs_users_dialog_button_add": "Hinzufügen", - "prefs_users_dialog_button_save": "Speichern", + "common_cancel": "Abbrechen", + "common_add": "Hinzufügen", + "common_save": "Speichern", "prefs_appearance_language_title": "Sprache", "notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.", "publish_dialog_message_placeholder": "Gib hier eine Nachricht ein", @@ -25,13 +25,13 @@ "notifications_click_copy_url_title": "Link-URL in Zwischenablage kopieren", "publish_dialog_priority_low": "Niedrige Priorität", "publish_dialog_message_label": "Nachricht", - "action_bar_unsubscribe": "Von Thema abmelden", + "action_bar_unsubscribe": "Abmelden", "notifications_copied_to_clipboard": "In Zwischenablage kopiert", "notifications_loading": "Benachrichtigungen werden geladen …", "notifications_attachment_open_title": "Gehe zu {{url}}", "notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.", "action_bar_send_test_notification": "Test-Benachrichtigung senden", - "alert_grant_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.", + "alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.", "notifications_tags": "Tags", "message_bar_type_message": "Gib hier eine Nachricht ein", "message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung", @@ -39,7 +39,7 @@ "alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt.", "action_bar_settings": "Einstellungen", "action_bar_clear_notifications": "Alle Benachrichtigungen löschen", - "alert_grant_button": "Jetzt erlauben", + "alert_notification_permission_required_button": "Jetzt erlauben", "notifications_none_for_topic_title": "Du hast für dieses Thema noch keine Benachrichtigungen empfangen.", "notifications_click_open_button": "Link öffnen", "notifications_more_details": "Ausführlichere Informationen findest Du auf der Website und in der Dokumentation.", @@ -62,7 +62,7 @@ "publish_dialog_progress_uploading_detail": "Hochladen {{loaded}}/{{total}} ({{percent}} %) …", "publish_dialog_priority_max": "Max. Priorität", "publish_dialog_topic_placeholder": "Thema, z.B. phil_alerts", - "publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{filesizeLimit}}", + "publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{fileSizeLimit}}", "publish_dialog_topic_label": "Thema", "publish_dialog_priority_default": "Standard-Priorität", "publish_dialog_base_url_placeholder": "Service-URL, z.B. https://example.com", @@ -82,7 +82,7 @@ "publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk", "publish_dialog_filename_placeholder": "Dateiname des Anhangs", "publish_dialog_delay_label": "Verzögerung", - "publish_dialog_email_placeholder": "Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@beispiel.com", + "publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z. B. phil@example.com", "publish_dialog_chip_click_label": "Klick-URL", "publish_dialog_button_cancel_sending": "Senden abbrechen", "publish_dialog_drop_file_here": "Datei hierher ziehen", @@ -94,7 +94,7 @@ "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "prefs_appearance_title": "Darstellung", "subscribe_dialog_login_password_label": "Kennwort", - "subscribe_dialog_login_button_back": "Zurück", + "common_back": "Zurück", "publish_dialog_chip_attach_url_label": "Datei von URL anhängen", "publish_dialog_chip_delay_label": "Auslieferung verzögern", "publish_dialog_chip_topic_label": "Thema ändern", @@ -152,5 +152,233 @@ "prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht", "priority_min": "min", "notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt", - "notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}" + "notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}", + "action_bar_show_menu": "Menü anzeigen", + "action_bar_toggle_mute": "Stummschaltung an/aus", + "message_bar_show_dialog": "Dialog zur Veröffentlichung anzeigen", + "message_bar_publish": "Benachrichtigung veröffentlichen", + "nav_button_connecting": "verbinde", + "notifications_list": "Benachrichtigungsliste", + "notifications_mark_read": "Als gelesen markieren", + "notifications_delete": "Löschen", + "notifications_priority_x": "Priorität {{priority}}", + "notifications_attachment_file_image": "Bilddatei", + "notifications_attachment_image": "Bild des Anhangs", + "notifications_attachment_file_video": "Videodatei", + "notifications_attachment_file_audio": "Audiodatei", + "notifications_attachment_file_app": "Android App-Datei", + "notifications_attachment_file_document": "anderes Dokument", + "publish_dialog_attached_file_remove": "Angehängte Datei entfernen", + "emoji_picker_search_clear": "Suche leeren", + "subscribe_dialog_subscribe_base_url_label": "Service URL", + "prefs_notifications_sound_play": "Gewählten Sound abspielen", + "prefs_users_table": "Benutzertabelle", + "prefs_users_edit_button": "Benutzer bearbeiten", + "prefs_users_delete_button": "Benutzer löschen", + "error_boundary_unsupported_indexeddb_title": "Private Browser-Tabs werden nicht unterstützt", + "publish_dialog_delay_reset": "Verzögerte Zustellung entfernen", + "error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.

Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es in diesem GitHub-Issue, oder im Chat bei Discord oder Matrix.", + "action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen", + "notifications_new_indicator": "Neue Benachrichtigung", + "publish_dialog_email_reset": "Email-Weiterleitung entfernen", + "action_bar_logo_alt": "ntfy Logo", + "nav_button_muted": "Benachrichtigungen stummgeschaltet", + "notifications_list_item": "Benachrichtigung", + "publish_dialog_emoji_picker_show": "Emoji wählen", + "publish_dialog_topic_reset": "Thema zurücksetzen", + "publish_dialog_attach_reset": "angehängte URL entfernen", + "publish_dialog_click_reset": "Klick-URL entfernen", + "account_tokens_delete_dialog_description": "Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. Diese Aktion kann nicht rückgängig gemacht werden.", + "account_upgrade_dialog_cancel_warning": "Dies wird Dein Abo stornieren und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten gelöscht.", + "prefs_reservations_table_everyone_read_write": "Jeder kann veröffentlichen und lesen", + "prefs_reservations_table_everyone_read_only": "Ich kann veröffentlichen und lesen, jeder kann lesen", + "prefs_reservations_table_access_header": "Zugriff", + "account_tokens_dialog_button_cancel": "Abbrechen", + "account_tokens_dialog_expires_x_hours": "Token verfällt in {{hours}} Stunden", + "account_tokens_dialog_expires_never": "Token verfällt nie", + "signup_form_username": "Benutzername", + "signup_form_button_submit": "Konto anlegen", + "signup_already_have_account": "Du hast schon ein Konto? Melde Dich an!", + "signup_disabled": "Die Anmeldung ist deaktiviert", + "login_title": "Melde Dich mit Deinem ntfy-Konto an", + "login_form_button_submit": "Anmelden", + "login_link_signup": "Konto erstellen", + "login_disabled": "Anmeldung ist deaktiviert", + "action_bar_account": "Konto", + "action_bar_change_display_name": "Anzeigenamen ändern", + "action_bar_reservation_add": "Thema reservieren", + "action_bar_reservation_edit": "Reservierung ändern", + "action_bar_reservation_delete": "Reservierung löschen", + "action_bar_reservation_limit_reached": "Grenze erreicht", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Einstellungen", + "action_bar_profile_logout": "Abmelden", + "action_bar_sign_in": "Anmelden", + "signup_form_password": "Kennwort", + "signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten", + "nav_button_account": "Konto", + "nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge", + "display_name_dialog_title": "Anzeigennamen ändern", + "display_name_dialog_placeholder": "Anzeigename", + "reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren", + "subscribe_dialog_error_topic_already_reserved": "Thema ist bereits reserviert", + "account_basics_username_title": "Benutzername", + "account_basics_username_description": "Hey, das bist Du ❤", + "account_basics_password_description": "Konto-Kennwort ändern", + "account_basics_password_dialog_title": "Kennwort ändern", + "account_basics_password_dialog_current_password_label": "Aktuelles Kennwort", + "account_basics_password_dialog_new_password_label": "Neues Kennwort", + "account_basics_password_dialog_confirm_password_label": "Kennwort bestätigen", + "account_basics_password_dialog_current_password_incorrect": "Kennwort falsch", + "account_usage_title": "Verbrauch", + "account_usage_of_limit": "von {{limit}}", + "account_usage_unlimited": "unbegrenzt", + "account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt", + "account_basics_password_title": "Kennwort", + "account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels", + "account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(kein Level)", + "account_basics_tier_admin": "Admin", + "account_basics_tier_basic": "Basic", + "account_basics_tier_free": "Kostenlos", + "account_basics_tier_paid_until": "Abo bezahlt bis {{date}} mit automatischer Verlängerung", + "account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.", + "account_basics_tier_manage_billing_button": "Zahlung verwalten", + "account_usage_messages_title": "Veröffentlichte Nachrichten", + "account_usage_emails_title": "Gesendete Emails", + "account_usage_reservations_title": "Reservierte Themen", + "account_usage_reservations_none": "Keine reservierten Themen für dieses Konto", + "account_usage_attachment_storage_title": "Speicherplatz für Anhänge", + "account_usage_attachment_storage_description": "{{filesize}} pro Datei, Löschung nach {{expiry}}", + "account_usage_cannot_create_portal_session": "Kann Abrechnungsportal nicht öffnen", + "account_delete_title": "Konto löschen", + "account_delete_description": "Konto endgültig löschen", + "account_delete_dialog_label": "Kennwort", + "account_delete_dialog_button_cancel": "Abbrechen", + "account_delete_dialog_button_submit": "Lösche mein Konto endgültig", + "account_basics_tier_change_button": "Wechseln", + "account_basics_tier_canceled_subscription": "Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.", + "account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.", + "account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.", + "account_upgrade_dialog_title": "Konto-Level ändern", + "account_upgrade_dialog_proration_info": "Anrechnung: Wenn Du auf einen höheren kostenpflichtigen Level wechselst wird die Differenz sofort berechnet. Beim Wechsel auf ein kleineres Level verwenden wir Dein Guthaben für zukünftige Abrechnungsperioden.", + "account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung. Du kannst Reservierungen in den Einstellungen löschen.", + "account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen. Du kannst Reservierungen in den Einstellungen löschen.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reservierte Themen", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} Nachrichten pro Tag", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} Emails pro Tag", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz", + "account_upgrade_dialog_tier_selected_label": "Ausgewählt", + "account_upgrade_dialog_tier_current_label": "Aktuell", + "account_upgrade_dialog_button_cancel": "Abbrechen", + "account_upgrade_dialog_button_redirect_signup": "Jetzt ein Konto anlegen", + "account_upgrade_dialog_button_pay_now": "Jetzt bezahlen und abonnieren", + "account_upgrade_dialog_button_cancel_subscription": "Abo stornieren", + "account_upgrade_dialog_button_update_subscription": "Abo aktualisieren", + "account_tokens_title": "Access-Token", + "account_tokens_description": "Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die Dokumentation für mehr Info.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Bezeichnung", + "account_tokens_table_last_access_header": "Letzter Zugriff", + "account_tokens_table_expires_header": "Verfällt", + "account_tokens_table_never_expires": "Verfällt nie", + "account_tokens_table_current_session": "Aktuelle Browser-Sitzung", + "common_copy_to_clipboard": "In die Zwischenablage kopieren", + "account_tokens_table_copied_to_clipboard": "Access-Token kopiert", + "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", + "account_tokens_table_create_token_button": "Access-Token erzeugen", + "account_tokens_table_last_origin_tooltip": "Von IP-Adresse {{ip}}, klicke zum Nachschlagen", + "account_tokens_dialog_title_create": "Access-Token erzeugen", + "account_tokens_dialog_title_edit": "Access-Token bearbeiten", + "account_tokens_dialog_title_delete": "Access-Token löschen", + "account_tokens_dialog_label": "Bezeichnung, z.B. Radarr Benachrichtigungen", + "account_tokens_dialog_button_create": "Token erzeugen", + "account_tokens_dialog_button_update": "Token aktualisieren", + "account_tokens_dialog_expires_label": "Access-Token verfällt in", + "account_tokens_dialog_expires_unchanged": "Verfallsdatum nicht ändern", + "account_tokens_dialog_expires_x_days": "Token verfällt in {{days}} Tagen", + "account_tokens_delete_dialog_title": "Access-Token löschen", + "account_tokens_delete_dialog_submit_button": "Token endgültig löschen", + "prefs_users_description_no_sync": "Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.", + "prefs_users_table_cannot_delete_or_edit": "Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden", + "prefs_reservations_title": "Reservierte Themen", + "prefs_reservations_description": "Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.", + "prefs_reservations_limit_reached": "Du hast Dein Limit an reservierten Themen erreicht.", + "prefs_reservations_add_button": "Reserviertes Thema hinzufügen", + "prefs_reservations_edit_button": "Zugriff auf Thema bearbeiten", + "prefs_reservations_delete_button": "Zugriff auf Thema zurücksetzen", + "prefs_reservations_table": "Übersicht reservierter Themen", + "prefs_reservations_table_topic_header": "Thema", + "prefs_reservations_table_everyone_deny_all": "Nur ich kann veröffentlichen und lesen", + "prefs_reservations_table_everyone_write_only": "Ich kann veröffentlichen und lesen, jeder kann veröffentlichen", + "prefs_reservations_table_not_subscribed": "Nicht abonniert", + "prefs_reservations_table_click_to_subscribe": "Klicken um zu abonnieren", + "prefs_reservations_dialog_title_add": "Thema reservieren", + "prefs_reservations_dialog_title_edit": "Reserviertes Thema bearbeiten", + "prefs_reservations_dialog_title_delete": "Thema-Reservierung löschen", + "prefs_reservations_dialog_description": "Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.", + "prefs_reservations_dialog_topic_label": "Thema", + "prefs_reservations_dialog_access_label": "Zugriff", + "reservation_delete_dialog_description": "Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.", + "reservation_delete_dialog_action_keep_title": "Behalte gecachte Nachrichten und Dateien", + "reservation_delete_dialog_action_keep_description": "Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.", + "reservation_delete_dialog_action_delete_title": "Löschen gecachte Nachrichten und Dateien", + "reservation_delete_dialog_action_delete_description": "Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "reservation_delete_dialog_submit_button": "Reservierung löschen", + "account_basics_password_dialog_button_submit": "Kennwort ändern", + "account_basics_tier_title": "Kontotyp", + "account_basics_tier_upgrade_button": "Upgrade auf Pro", + "account_delete_dialog_description": "Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.", + "signup_form_confirm_password": "Kennwort wiederholen", + "signup_title": "Erstelle ein ntfy-Konto", + "signup_error_username_taken": "Benutzername {{username}} ist bereits vergeben", + "signup_error_creation_limit_reached": "Grenze der Account-Erstellung erreicht", + "subscribe_dialog_subscribe_button_generate_topic_name": "Namen erzeugen", + "account_basics_title": "Konto", + "action_bar_sign_up": "Konto erstellen", + "nav_upgrade_banner_label": "Upgrade auf ntfy Pro", + "alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der Notifications API.", + "display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.", + "account_basics_username_admin_tooltip": "Du bist Admin", + "account_upgrade_dialog_interval_yearly_discount_save": "spare {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spare bis zu {{discount}}%", + "account_upgrade_dialog_tier_price_per_month": "Monat", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} pro Jahr. Spare {{save}}.", + "account_upgrade_dialog_billing_contact_email": "Bei Fragen zur Abrechnung, kontaktiere uns bitte direkt.", + "account_upgrade_dialog_billing_contact_website": "Bei Fragen zur Abrechnung sieh bitte auf unserer Webseite nach.", + "account_upgrade_dialog_tier_features_no_reservations": "Keine reservierten Themen", + "account_basics_tier_interval_yearly": "jährlich", + "account_basics_tier_interval_monthly": "monatlich", + "account_upgrade_dialog_interval_monthly": "Monatlich", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} pro Jahr. Monatlich abgerechnet.", + "account_upgrade_dialog_interval_yearly": "Jährlich", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} tägliche Nachricht", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail", + "publish_dialog_call_label": "Telefonanruf", + "publish_dialog_call_item": "Telefonnummer {{number}} anrufen", + "publish_dialog_chip_call_label": "Telefonanruf", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Keine verifizierten Telefonnummern", + "account_basics_phone_numbers_title": "Telefonnummern", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer wurde in die Zwischenablage kopiert", + "account_basics_phone_numbers_dialog_title": "Telefonnummer hinzufügen", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} Telefonanrufe pro Tag", + "account_upgrade_dialog_tier_features_no_calls": "Keine Telefonanrufe", + "publish_dialog_call_reset": "Telefonanruf entfernen", + "account_basics_phone_numbers_dialog_description": "Um die Benachrichtigung per Telefonanruf zu nutzen musst Du mindestens eine Telefonnummer hinzufügen und verifizieren. Die Verifizierung kann per SMS oder über einen Anruf erfolgen.", + "account_basics_phone_numbers_description": "Für Telefon-Benachrichtigungen", + "account_basics_phone_numbers_no_phone_numbers_yet": "Noch keine Telefonnummern", + "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Anruf", + "account_basics_phone_numbers_dialog_number_placeholder": "z.B. +49123456789", + "account_basics_phone_numbers_dialog_verify_button_call": "Ruf mich an", + "account_basics_phone_numbers_dialog_verify_button_sms": "SMS senden", + "account_basics_phone_numbers_dialog_code_label": "Verifizierungs-Code", + "account_basics_phone_numbers_dialog_code_placeholder": "z.B. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen", + "account_usage_calls_title": "Getätigte Anrufe", + "account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag" } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index ee6197c5..3ad04ea7 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -1,32 +1,73 @@ { + "common_cancel": "Cancel", + "common_save": "Save", + "common_add": "Add", + "common_back": "Back", + "common_copy_to_clipboard": "Copy to clipboard", + "signup_title": "Create a ntfy account", + "signup_form_username": "Username", + "signup_form_password": "Password", + "signup_form_confirm_password": "Confirm password", + "signup_form_button_submit": "Sign up", + "signup_form_toggle_password_visibility": "Toggle password visibility", + "signup_already_have_account": "Already have an account? Sign in!", + "signup_disabled": "Signup is disabled", + "signup_error_username_taken": "Username {{username}} is already taken", + "signup_error_creation_limit_reached": "Account creation limit reached", + "login_title": "Sign in to your ntfy account", + "login_form_button_submit": "Sign in", + "login_link_signup": "Sign up", + "login_disabled": "Login is disabled", "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", "action_bar_settings": "Settings", + "action_bar_account": "Account", + "action_bar_change_display_name": "Change display name", + "action_bar_reservation_add": "Reserve topic", + "action_bar_reservation_edit": "Change reservation", + "action_bar_reservation_delete": "Remove reservation", + "action_bar_reservation_limit_reached": "Limit reached", "action_bar_send_test_notification": "Send test notification", "action_bar_clear_notifications": "Clear all notifications", + "action_bar_mute_notifications": "Mute notifications", + "action_bar_unmute_notifications": "Unmute notifications", "action_bar_unsubscribe": "Unsubscribe", "action_bar_toggle_mute": "Mute/unmute notifications", "action_bar_toggle_action_menu": "Open/close action menu", + "action_bar_profile_title": "Profile", + "action_bar_profile_settings": "Settings", + "action_bar_profile_logout": "Logout", + "action_bar_sign_in": "Sign in", + "action_bar_sign_up": "Sign up", "message_bar_type_message": "Type a message here", "message_bar_error_publishing": "Error publishing notification", "message_bar_show_dialog": "Show publish dialog", "message_bar_publish": "Publish message", "nav_topics_title": "Subscribed topics", "nav_button_all_notifications": "All notifications", + "nav_button_account": "Account", "nav_button_settings": "Settings", "nav_button_documentation": "Documentation", "nav_button_publish_message": "Publish notification", "nav_button_subscribe": "Subscribe to topic", "nav_button_muted": "Notifications muted", "nav_button_connecting": "connecting", - "alert_grant_title": "Notifications are disabled", - "alert_grant_description": "Grant your browser permission to display desktop notifications.", - "alert_grant_button": "Grant now", + "nav_upgrade_banner_label": "Upgrade to ntfy Pro", + "nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments", + "alert_notification_permission_required_title": "Notifications are disabled", + "alert_notification_permission_required_description": "Grant your browser permission to display desktop notifications", + "alert_notification_permission_required_button": "Grant now", + "alert_notification_permission_denied_title": "Notifications are blocked", + "alert_notification_permission_denied_description": "Please re-enable them in your browser", + "alert_notification_ios_install_required_title": "iOS install required", + "alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS", "alert_not_supported_title": "Notifications not supported", - "alert_not_supported_description": "Notifications are not supported in your browser.", + "alert_not_supported_description": "Notifications are not supported in your browser", + "alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the Notifications API.", "notifications_list": "Notifications list", "notifications_list_item": "Notification", - "notifications_delete": "Delete notification", + "notifications_mark_read": "Mark as read", + "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", "notifications_priority_x": "Priority {{priority}}", @@ -49,6 +90,7 @@ "notifications_actions_open_url_title": "Go to {{url}}", "notifications_actions_not_supported": "Action not supported in web app", "notifications_actions_http_request_title": "Send HTTP {{method}} to {{url}}", + "notifications_actions_failed_notification": "Unsuccessful action", "notifications_none_for_topic_title": "You haven't received any notifications for this topic yet.", "notifications_none_for_topic_description": "To send notifications to this topic, simply PUT or POST to the topic URL.", "notifications_none_for_any_title": "You haven't received any notifications.", @@ -57,6 +99,10 @@ "notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.", "notifications_example": "Example", "notifications_more_details": "For more information, check out the website or documentation.", + "display_name_dialog_title": "Change display name", + "display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.", + "display_name_dialog_placeholder": "Display name", + "reserve_dialog_checkbox_label": "Reserve topic and configure access", "notifications_loading": "Loading notifications …", "publish_dialog_title_topic": "Publish to {{topic}}", "publish_dialog_title_no_topic": "Publish notification", @@ -90,6 +136,9 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", + "publish_dialog_call_label": "Phone call", + "publish_dialog_call_item": "Call phone number {{number}}", + "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -101,6 +150,8 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", + "publish_dialog_chip_call_label": "Phone call", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "No verified phone numbers", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_delay_label": "Delay delivery", @@ -109,6 +160,7 @@ "publish_dialog_button_cancel_sending": "Cancel sending", "publish_dialog_button_cancel": "Cancel", "publish_dialog_button_send": "Send", + "publish_dialog_checkbox_markdown": "Format as Markdown", "publish_dialog_checkbox_publish_another": "Publish another", "publish_dialog_attached_file_title": "Attached file:", "publish_dialog_attached_file_filename_placeholder": "Attachment filename", @@ -120,17 +172,142 @@ "subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.", "subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts", "subscribe_dialog_subscribe_use_another_label": "Use another server", + "subscribe_dialog_subscribe_use_another_background_info": "Notifications from other servers will not be received when the web app is not open", "subscribe_dialog_subscribe_base_url_label": "Service URL", + "subscribe_dialog_subscribe_button_generate_topic_name": "Generate name", "subscribe_dialog_subscribe_button_cancel": "Cancel", "subscribe_dialog_subscribe_button_subscribe": "Subscribe", "subscribe_dialog_login_title": "Login required", "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", "subscribe_dialog_login_username_label": "Username, e.g. phil", "subscribe_dialog_login_password_label": "Password", - "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", + "subscribe_dialog_error_topic_already_reserved": "Topic already reserved", "subscribe_dialog_error_user_anonymous": "anonymous", + "account_basics_title": "Account", + "account_basics_username_title": "Username", + "account_basics_username_description": "Hey, that's you ❤", + "account_basics_username_admin_tooltip": "You are Admin", + "account_basics_password_title": "Password", + "account_basics_password_description": "Change your account password", + "account_basics_password_dialog_title": "Change password", + "account_basics_password_dialog_current_password_label": "Current password", + "account_basics_password_dialog_new_password_label": "New password", + "account_basics_password_dialog_confirm_password_label": "Confirm password", + "account_basics_password_dialog_button_submit": "Change password", + "account_basics_password_dialog_current_password_incorrect": "Password incorrect", + "account_basics_phone_numbers_title": "Phone numbers", + "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.", + "account_basics_phone_numbers_description": "For phone call notifications", + "account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet", + "account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard", + "account_basics_phone_numbers_dialog_title": "Add phone number", + "account_basics_phone_numbers_dialog_number_label": "Phone number", + "account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Call me", + "account_basics_phone_numbers_dialog_code_label": "Verification code", + "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Call", + "account_usage_title": "Usage", + "account_usage_of_limit": "of {{limit}}", + "account_usage_unlimited": "Unlimited", + "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", + "account_basics_tier_title": "Account type", + "account_basics_tier_description": "Your account's power level", + "account_basics_tier_admin": "Admin", + "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)", + "account_basics_tier_admin_suffix_no_tier": "(no tier)", + "account_basics_tier_basic": "Basic", + "account_basics_tier_free": "Free", + "account_basics_tier_interval_monthly": "monthly", + "account_basics_tier_interval_yearly": "annually", + "account_basics_tier_upgrade_button": "Upgrade to Pro", + "account_basics_tier_change_button": "Change", + "account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew", + "account_basics_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.", + "account_basics_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.", + "account_basics_tier_manage_billing_button": "Manage billing", + "account_usage_messages_title": "Published messages", + "account_usage_emails_title": "Emails sent", + "account_usage_calls_title": "Phone calls made", + "account_usage_calls_none": "No phone calls can be made with this account", + "account_usage_reservations_title": "Reserved topics", + "account_usage_reservations_none": "No reserved topics for this account", + "account_usage_attachment_storage_title": "Attachment storage", + "account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}", + "account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.", + "account_usage_cannot_create_portal_session": "Unable to open billing portal", + "account_delete_title": "Delete account", + "account_delete_description": "Permanently delete your account", + "account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. After deletion, your username will be unavailable for 7 days. If you really want to proceed, please confirm with your password in the box below.", + "account_delete_dialog_label": "Password", + "account_delete_dialog_button_cancel": "Cancel", + "account_delete_dialog_button_submit": "Permanently delete account", + "account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.", + "account_upgrade_dialog_title": "Change account tier", + "account_upgrade_dialog_interval_monthly": "Monthly", + "account_upgrade_dialog_interval_yearly": "Annually", + "account_upgrade_dialog_interval_yearly_discount_save": "save {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "save up to {{discount}}%", + "account_upgrade_dialog_cancel_warning": "This will cancel your subscription, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server will be deleted.", + "account_upgrade_dialog_proration_info": "Proration: When upgrading between paid plans, the price difference will be charged immediately. When downgrading to a lower tier, the balance will be used to pay for future billing periods.", + "account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, please delete at least one reservation. You can remove reservations in the Settings.", + "account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, please delete at least {{count}} reservations. You can remove reservations in the Settings.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserved topic", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserved topics", + "account_upgrade_dialog_tier_features_no_reservations": "No reserved topics", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} daily message", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_no_calls": "No phone calls", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", + "account_upgrade_dialog_tier_price_per_month": "month", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.", + "account_upgrade_dialog_tier_selected_label": "Selected", + "account_upgrade_dialog_tier_current_label": "Current", + "account_upgrade_dialog_billing_contact_email": "For billing questions, please contact us directly.", + "account_upgrade_dialog_billing_contact_website": "For billing questions, please refer to our website.", + "account_upgrade_dialog_button_cancel": "Cancel", + "account_upgrade_dialog_button_redirect_signup": "Sign up now", + "account_upgrade_dialog_button_pay_now": "Pay now and subscribe", + "account_upgrade_dialog_button_cancel_subscription": "Cancel subscription", + "account_upgrade_dialog_button_update_subscription": "Update subscription", + "account_tokens_title": "Access tokens", + "account_tokens_description": "Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the documentation to learn more.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Label", + "account_tokens_table_last_access_header": "Last access", + "account_tokens_table_expires_header": "Expires", + "account_tokens_table_never_expires": "Never expires", + "account_tokens_table_current_session": "Current browser session", + "account_tokens_table_copied_to_clipboard": "Access token copied", + "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", + "account_tokens_table_create_token_button": "Create access token", + "account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup", + "account_tokens_dialog_title_create": "Create access token", + "account_tokens_dialog_title_edit": "Edit access token", + "account_tokens_dialog_title_delete": "Delete access token", + "account_tokens_dialog_label": "Label, e.g. Radarr notifications", + "account_tokens_dialog_button_create": "Create token", + "account_tokens_dialog_button_update": "Update token", + "account_tokens_dialog_button_cancel": "Cancel", + "account_tokens_dialog_expires_label": "Access token expires in", + "account_tokens_dialog_expires_unchanged": "Leave expiry date unchanged", + "account_tokens_dialog_expires_x_hours": "Token expires in {{hours}} hours", + "account_tokens_dialog_expires_x_days": "Token expires in {{days}} days", + "account_tokens_dialog_expires_never": "Token never expires", + "account_tokens_delete_dialog_title": "Delete access token", + "account_tokens_delete_dialog_description": "Before deleting an access token, be sure that no applications or scripts are actively using it. This action cannot be undone.", + "account_tokens_delete_dialog_submit_button": "Permanently delete token", "prefs_notifications_title": "Notifications", "prefs_notifications_sound_title": "Notification sound", "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", @@ -157,12 +334,19 @@ "prefs_notifications_delete_after_one_day_description": "Notifications are auto-deleted after one day", "prefs_notifications_delete_after_one_week_description": "Notifications are auto-deleted after one week", "prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month", + "prefs_notifications_web_push_title": "Background notifications", + "prefs_notifications_web_push_enabled_description": "Notifications are received even when the web app is not running (via Web Push)", + "prefs_notifications_web_push_disabled_description": "Notification are received when the web app is running (via WebSocket)", + "prefs_notifications_web_push_enabled": "Enabled for {{server}}", + "prefs_notifications_web_push_disabled": "Disabled", "prefs_users_title": "Manage users", "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", + "prefs_users_description_no_sync": "Users and passwords are not synchronized to your account.", "prefs_users_table": "Users table", "prefs_users_add_button": "Add user", "prefs_users_edit_button": "Edit user", "prefs_users_delete_button": "Delete user", + "prefs_users_table_cannot_delete_or_edit": "Cannot delete or edit logged in user", "prefs_users_table_user_header": "User", "prefs_users_table_base_url_header": "Service URL", "prefs_users_dialog_title_add": "Add user", @@ -170,11 +354,39 @@ "prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh", "prefs_users_dialog_username_label": "Username, e.g. phil", "prefs_users_dialog_password_label": "Password", - "prefs_users_dialog_button_cancel": "Cancel", - "prefs_users_dialog_button_add": "Add", - "prefs_users_dialog_button_save": "Save", "prefs_appearance_title": "Appearance", "prefs_appearance_language_title": "Language", + "prefs_appearance_theme_title": "Theme", + "prefs_appearance_theme_system": "System (default)", + "prefs_appearance_theme_dark": "Dark mode", + "prefs_appearance_theme_light": "Light mode", + "prefs_reservations_title": "Reserved topics", + "prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.", + "prefs_reservations_limit_reached": "You reached your reserved topics limit.", + "prefs_reservations_add_button": "Add reserved topic", + "prefs_reservations_edit_button": "Edit topic access", + "prefs_reservations_delete_button": "Reset topic access", + "prefs_reservations_table": "Reserved topics table", + "prefs_reservations_table_topic_header": "Topic", + "prefs_reservations_table_access_header": "Access", + "prefs_reservations_table_everyone_deny_all": "Only I can publish and subscribe", + "prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe", + "prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish", + "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", + "prefs_reservations_table_not_subscribed": "Not subscribed", + "prefs_reservations_table_click_to_subscribe": "Click to subscribe", + "prefs_reservations_dialog_title_add": "Reserve topic", + "prefs_reservations_dialog_title_edit": "Edit reserved topic", + "prefs_reservations_dialog_title_delete": "Delete topic reservation", + "prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.", + "prefs_reservations_dialog_topic_label": "Topic", + "prefs_reservations_dialog_access_label": "Access", + "reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.", + "reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments", + "reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.", + "reservation_delete_dialog_action_delete_title": "Delete cached messages and attachments", + "reservation_delete_dialog_action_delete_description": "Cached messages and attachments will be permanently deleted. This action cannot be undone.", + "reservation_delete_dialog_submit_button": "Delete reservation", "priority_min": "min", "priority_low": "low", "priority_default": "default", @@ -183,8 +395,13 @@ "error_boundary_title": "Oh no, ntfy crashed", "error_boundary_description": "This should obviously not happen. Very sorry about this.
If you have a minute, please report this on GitHub, or let us know via Discord or Matrix.", "error_boundary_button_copy_stack_trace": "Copy stack trace", + "error_boundary_button_reload_ntfy": "Reload ntfy", "error_boundary_stack_trace": "Stack trace", "error_boundary_gathering_info": "Gather more info …", "error_boundary_unsupported_indexeddb_title": "Private browsing not supported", - "error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.

While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it in this GitHub issue, or talk to us on Discord or Matrix." + "error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.

While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it in this GitHub issue, or talk to us on Discord or Matrix.", + "web_push_subscription_expiring_title": "Notifications will be paused", + "web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications", + "web_push_unknown_notification_title": "Unknown notification received from server", + "web_push_unknown_notification_body": "You may need to update ntfy by opening the web app" } diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index ac88dd82..045e43df 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -3,12 +3,12 @@ "action_bar_send_test_notification": "Enviar notificación de prueba", "action_bar_clear_notifications": "Borrar todas las notificaciones", "nav_topics_title": "Tópicos suscritos", - "alert_grant_button": "Conceder ahora", + "alert_notification_permission_required_button": "Conceder ahora", "action_bar_unsubscribe": "Cancelar la suscripción", "message_bar_type_message": "Escriba un mensaje aquí", "message_bar_error_publishing": "Error al publicar la notificación", - "alert_grant_title": "Las notificaciones están deshabilitadas", - "alert_grant_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.", + "alert_notification_permission_required_title": "Las notificaciones están deshabilitadas", + "alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.", "nav_button_all_notifications": "Todas las notificaciones", "nav_button_settings": "Ajustes", "nav_button_subscribe": "Suscribirse al tópico", @@ -74,14 +74,14 @@ "publish_dialog_drop_file_here": "Suelta el archivo aquí", "emoji_picker_search_placeholder": "Buscar emojis", "subscribe_dialog_subscribe_title": "Suscribirse al tópico", - "subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/PIST de notificaciones.", + "subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/POST de notificaciones.", "subscribe_dialog_subscribe_topic_placeholder": "Nombre del tópico, ej. phil_alerts", "subscribe_dialog_subscribe_use_another_label": "Usar otro servidor", "subscribe_dialog_login_title": "Es necesario iniciar sesión", "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.", "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil", "subscribe_dialog_login_password_label": "Contraseña", - "subscribe_dialog_login_button_back": "Volver", + "common_back": "Volver", "subscribe_dialog_login_button_login": "Iniciar sesión", "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_anonymous": "anónimo", @@ -101,13 +101,13 @@ "prefs_users_add_button": "Añadir usuario", "prefs_users_dialog_title_edit": "Editar usuario", "prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh", - "prefs_users_dialog_button_add": "Añadir", - "prefs_users_dialog_button_save": "Guardar", + "common_add": "Añadir", + "common_save": "Guardar", "prefs_appearance_title": "Apariencia", "prefs_appearance_language_title": "Idioma", "error_boundary_title": "Oh no, ntfy tuvo un error", "error_boundary_button_copy_stack_trace": "Copiar el stack trace", - "error_boundary_stack_trace": "Stack trace", + "error_boundary_stack_trace": "Rastreo de pila", "error_boundary_gathering_info": "Reunir más información …", "notifications_example": "Ejemplo", "prefs_notifications_min_priority_title": "Prioridad mínima", @@ -134,7 +134,7 @@ "prefs_users_dialog_password_label": "Contraseña", "error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.
Si tienes un minuto, por favor informa de esto en GitHub, o avísanos vía Discord o Matrix.", "prefs_users_dialog_title_add": "Añadir usuario", - "prefs_users_dialog_button_cancel": "Cancelar", + "common_cancel": "Cancelar", "prefs_users_dialog_username_label": "Nombre de usuario, ej. phil", "priority_max": "máx", "priority_high": "alta", @@ -152,5 +152,234 @@ "prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana", "priority_low": "baja", "notifications_actions_not_supported": "Acción no soportada en la aplicación web", - "notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}" + "notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}", + "error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada.

Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto en este issue de GitHub, o hablar con nosotros en Discord o Matrix.", + "action_bar_show_menu": "Mostrar menú", + "action_bar_logo_alt": "logo de ntfy", + "action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción", + "message_bar_show_dialog": "Mostrar diálogo de publicación", + "message_bar_publish": "Publicar mensaje", + "nav_button_muted": "Notificaciones silenciadas", + "nav_button_connecting": "conectando", + "notifications_list": "Lista de notificaciones", + "notifications_list_item": "Notificación", + "notifications_mark_read": "Marcar como leído", + "notifications_delete": "Eliminar", + "notifications_priority_x": "Prioridad {{priority}}", + "notifications_new_indicator": "Nueva notificación", + "notifications_attachment_image": "Imagen adjunta", + "notifications_attachment_file_image": "archivo de imagen", + "notifications_attachment_file_video": "archivo de video", + "notifications_attachment_file_audio": "archivo de audio", + "notifications_attachment_file_app": "Archivo de aplicación de Android", + "notifications_attachment_file_document": "otro documento", + "action_bar_toggle_mute": "Silenciar/reactivar notificaciones", + "publish_dialog_emoji_picker_show": "Elige un emoji", + "publish_dialog_topic_reset": "Restablecer tópico", + "publish_dialog_click_reset": "Eliminar URL de clic", + "publish_dialog_email_reset": "Eliminar el reenvío de correo electrónico", + "publish_dialog_attach_reset": "Eliminar la URL del archivo adjunto", + "publish_dialog_delay_reset": "Eliminar entrega retrasada", + "publish_dialog_attached_file_remove": "Eliminar el archivo adjunto", + "emoji_picker_search_clear": "Limpiar búsqueda", + "subscribe_dialog_subscribe_base_url_label": "URL del servicio", + "prefs_notifications_sound_play": "Reproducir el sonido seleccionado", + "prefs_users_table": "Tabla de usuarios", + "prefs_users_edit_button": "Editar usuario", + "prefs_users_delete_button": "Eliminar usuario", + "error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada", + "action_bar_profile_title": "Perfil", + "action_bar_profile_settings": "Configuración", + "signup_title": "Crear una cuenta ntfy", + "signup_form_username": "Nombre de usuario", + "signup_form_password": "Contraseña", + "signup_form_confirm_password": "Confirmar contraseña", + "signup_form_button_submit": "Registro", + "signup_form_toggle_password_visibility": "Alternar la visibilidad de la contraseña", + "signup_already_have_account": "¿Ya tienes una cuenta? ¡Iniciar sesión!", + "signup_disabled": "El registro está deshabilitado", + "signup_error_username_taken": "El nombre de usuario {{username}} ya está en uso", + "signup_error_creation_limit_reached": "Límite de creación de cuenta alcanzado", + "login_title": "Inicie sesión en su cuenta ntfy", + "login_form_button_submit": "Iniciar sesión", + "login_link_signup": "Registro", + "login_disabled": "Inicio de sesión deshabilitado", + "action_bar_account": "Cuenta", + "action_bar_change_display_name": "Cambiar nombre de usuario", + "action_bar_reservation_add": "Reservar tema", + "action_bar_reservation_edit": "Modificar reserva", + "action_bar_reservation_delete": "Quitar reserva", + "action_bar_reservation_limit_reached": "Límite alcanzado", + "action_bar_profile_logout": "Cerrar sesión", + "action_bar_sign_in": "Iniciar sesión", + "action_bar_sign_up": "Registro", + "nav_button_account": "Cuenta", + "nav_upgrade_banner_label": "Actualizar a ntfy Pro", + "nav_upgrade_banner_description": "Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes", + "display_name_dialog_title": "Cambiar el nombre para mostrar", + "display_name_dialog_description": "Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.", + "display_name_dialog_placeholder": "Nombre para mostrar", + "account_basics_username_admin_tooltip": "Eres Administrador", + "account_basics_password_description": "Cambiar la contraseña de tu cuenta", + "account_basics_password_dialog_confirm_password_label": "Confirmar contraseña", + "account_basics_password_dialog_button_submit": "Cambiar contraseña", + "account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta", + "account_usage_unlimited": "Ilimitado", + "account_usage_title": "Uso", + "account_usage_of_limit": "de {{límite}}", + "account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)", + "account_basics_tier_description": "Nivel de poder de tu cuenta", + "account_basics_tier_admin": "Administrador", + "alert_not_supported_context_description": "Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la API de notificaciones .", + "reserve_dialog_checkbox_label": "Reservar tópico y configurar el acceso", + "subscribe_dialog_subscribe_button_generate_topic_name": "Generar nombre", + "subscribe_dialog_error_topic_already_reserved": "Tópico ya reservado", + "account_basics_title": "Cuenta", + "account_basics_username_title": "Nombre de usuario", + "account_basics_username_description": "Hey, ese eres tú ❤", + "account_basics_password_title": "Contraseña", + "account_basics_password_dialog_title": "Cambiar contraseña", + "account_basics_password_dialog_current_password_label": "Contraseña actual", + "account_basics_password_dialog_new_password_label": "Contraseña nueva", + "account_basics_tier_basic": "Básico", + "account_basics_tier_admin_suffix_with_tier": "(con nivel {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(sin nivel)", + "account_basics_tier_free": "Gratis", + "account_basics_tier_upgrade_button": "Actualizar a Pro", + "account_basics_tier_change_button": "Cambiar", + "account_basics_tier_paid_until": "Suscripción pagada hasta {{fecha}}, y se renovará automáticamente", + "account_basics_tier_manage_billing_button": "Administrar la facturación", + "account_basics_tier_title": "Tipo de cuenta", + "account_tokens_description": "Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la documentación para obtener más información.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Etiqueta", + "account_tokens_table_last_access_header": "Último acceso", + "account_tokens_table_expires_header": "Expira", + "account_tokens_table_never_expires": "Nunca expira", + "account_tokens_table_current_session": "Sesión del navegador actual", + "common_copy_to_clipboard": "Copiar al portapapeles", + "account_tokens_table_copied_to_clipboard": "Token de acceso copiado", + "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual", + "account_tokens_table_create_token_button": "Crear token de acceso", + "account_tokens_table_last_origin_tooltip": "Desde la dirección IP {{ip}}, haga clic para buscar", + "account_tokens_dialog_title_create": "Crear token de acceso", + "account_tokens_dialog_title_edit": "Editar token de acceso", + "account_tokens_dialog_title_delete": "Eliminar token de acceso", + "account_tokens_dialog_label": "Etiqueta, por ejemplo, notificaciones de Radarr", + "account_tokens_dialog_button_create": "Crear token", + "prefs_reservations_table_everyone_write_only": "Puedo publicar y suscribirme, todo el mundo puede publicar", + "account_usage_messages_title": "Mensajes publicados", + "account_usage_reservations_title": "Tópicos reservados", + "account_usage_reservations_none": "No hay tópicos reservados para esta cuenta", + "account_usage_cannot_create_portal_session": "No se puede abrir el portal de facturación", + "account_upgrade_dialog_title": "Cambiar nivel de cuenta", + "account_basics_tier_payment_overdue": "Su pago ha vencido. Por favor actualice su método de pago o su cuenta será degradada en breve.", + "account_basics_tier_canceled_subscription": "Su suscripción fue cancelada y será degradada a una cuenta gratuita el {{date}}.", + "account_usage_emails_title": "Correos enviados", + "account_usage_attachment_storage_title": "Almacenamiento de archivos adjuntos", + "account_usage_attachment_storage_description": "{{filesize}} por archivo, eliminado después de {{expiry}}", + "account_usage_basis_ip_description": "Las estadísticas de uso y los límites de esta cuenta se basan en su dirección IP, por lo que podrían ser compartidos con otros usuarios. Los límites mostrados anteriormente son aproximados basados en los límites existentes.", + "account_delete_title": "Elimina cuenta", + "account_delete_dialog_button_cancel": "Cancelar", + "account_delete_dialog_billing_warning": "La eliminación de su cuenta también cancela su suscripción de facturación inmediatamente. Ya no tendrá acceso al panel de facturación.", + "account_upgrade_dialog_reservations_warning_one": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, por favor elimine al menos una reserva. Puede eliminar reservas en Configuración.", + "account_upgrade_dialog_tier_selected_label": "Seleccionado", + "account_upgrade_dialog_button_cancel": "Cancelar", + "account_upgrade_dialog_button_cancel_subscription": "Cancelar suscripción", + "account_tokens_title": "Tokens de acceso", + "account_delete_description": "Eliminar permanentemente su cuenta", + "account_delete_dialog_description": "Esto borrará permanentemente su cuenta, incluyendo todos los datos almacenados en el servidor. Tras la eliminación, su nombre de usuario no estará disponible durante 7 días. Si realmente desea continuar, por favor confirme su contraseña en la casilla de abajo.", + "account_delete_dialog_label": "Contraseña", + "account_delete_dialog_button_submit": "Eliminar permanentemente la cuenta", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} tópicos reservados", + "account_upgrade_dialog_cancel_warning": "Esto cancelará su suscripción y degradará su cuenta en {{date}}. En esa fecha, sus tópicos reservados y sus mensajes almacenados en caché en el servidor serán eliminados.", + "account_upgrade_dialog_proration_info": "Prorrateo: al actualizar entre planes pagos, la diferencia de precio se cobrará de inmediato. Al cambiar a un nivel inferior, el saldo se utilizará para pagar futuros períodos de facturación.", + "account_upgrade_dialog_reservations_warning_other": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, por favor elimine al menos {{count}} reservaciones. Puede eliminar reservaciones en Configuración.", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensajes diarios", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} correos diarios", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por archivo", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} almacenamiento total", + "account_upgrade_dialog_tier_current_label": "Actual", + "account_upgrade_dialog_button_redirect_signup": "Regístrese ahora", + "account_upgrade_dialog_button_pay_now": "Pague ahora y suscríbase", + "account_upgrade_dialog_button_update_subscription": "Actualizar suscripción", + "account_tokens_dialog_button_update": "Actualizar token", + "account_tokens_dialog_expires_label": "El token de acceso expira en", + "prefs_reservations_table": "Tabla de tópicos reservados", + "prefs_reservations_dialog_description": "Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.", + "account_tokens_dialog_button_cancel": "Cancelar", + "account_tokens_dialog_expires_unchanged": "No modificar la fecha de expiración", + "prefs_reservations_add_button": "Agregar tópico reservado", + "prefs_reservations_table_access_header": "Acceso", + "reservation_delete_dialog_action_delete_description": "Los mensajes y archivos adjuntos almacenados en caché se eliminarán de forma permanente. Esta acción no se puede deshacer.", + "account_tokens_dialog_expires_x_hours": "El token expira en {{hours}} horas", + "account_tokens_delete_dialog_title": "Eliminar token de acceso", + "prefs_reservations_limit_reached": "Ha alcanzado su límite de tópicos reservados.", + "prefs_reservations_table_everyone_read_write": "Todo el mundo puede publicar y suscribirse", + "reservation_delete_dialog_action_keep_description": "Los mensajes y archivos adjuntos que se almacenen en caché en el servidor pasarán a ser visibles públicamente para las personas que conozcan el nombre del tópico.", + "account_tokens_dialog_expires_x_days": "El token expira en {{days}} días", + "account_tokens_dialog_expires_never": "El token nunca expira", + "account_tokens_delete_dialog_description": "Antes de eliminar un token de acceso, asegúrese de que ninguna aplicación o script lo está utilizando activamente. Esta acción no se puede deshacer.", + "prefs_users_table_cannot_delete_or_edit": "No se puede eliminar o editar el usuario conectado", + "prefs_reservations_title": "Tópicos reservados", + "prefs_reservations_edit_button": "Editar acceso al tópico", + "prefs_reservations_table_topic_header": "Tópico", + "prefs_reservations_table_everyone_read_only": "Puedo publicar y suscribirme, todo el mundo puede suscribirse", + "prefs_reservations_table_everyone_deny_all": "Sólo yo puedo publicar y suscribirme", + "prefs_reservations_table_click_to_subscribe": "Haga clic para suscribirse", + "prefs_reservations_dialog_title_edit": "Edita tópico reservado", + "account_tokens_delete_dialog_submit_button": "Eliminar permanentemente el token", + "prefs_reservations_description": "Aquí puede reservar nombres de tópicos para uso personal. Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.", + "prefs_reservations_delete_button": "Restablecer acceso a tópico", + "prefs_reservations_table_not_subscribed": "No suscrito", + "prefs_reservations_dialog_title_add": "Reservar tópico", + "prefs_users_description_no_sync": "Los usuarios y las contraseñas no están sincronizados con su cuenta.", + "prefs_reservations_dialog_title_delete": "Borrar reserva de tópico", + "prefs_reservations_dialog_access_label": "Acceso", + "reservation_delete_dialog_action_keep_title": "Conservar mensajes y archivos adjuntos en caché", + "prefs_reservations_dialog_topic_label": "Tópico", + "reservation_delete_dialog_description": "Al eliminar una reserva se renuncia a la propiedad sobre el tópico y se permite que otros lo reserven. Puede conservar o eliminar los mensajes y archivos adjuntos existentes.", + "reservation_delete_dialog_action_delete_title": "Eliminar mensajes y archivos adjuntos en caché", + "reservation_delete_dialog_submit_button": "Eliminar reserva", + "account_basics_tier_interval_monthly": "mensualmente", + "account_basics_tier_interval_yearly": "anualmente", + "account_upgrade_dialog_interval_monthly": "Mensualmente", + "account_upgrade_dialog_interval_yearly": "Anualmente", + "account_upgrade_dialog_interval_yearly_discount_save": "ahorrar {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ahorra hasta un {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "Ningún tema reservado", + "account_upgrade_dialog_tier_price_per_month": "mes", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} facturado anualmente. Guardar {{save}}.", + "account_upgrade_dialog_billing_contact_website": "Si tiene preguntas sobre facturación, consulte nuestra página web.", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} al año. Facturación mensual.", + "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor contáctenos directamente.", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado", + "publish_dialog_call_label": "Llamada telefónica", + "publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"", + "publish_dialog_chip_call_label": "Llamada telefónica", + "account_basics_phone_numbers_title": "Números de teléfono", + "account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica", + "account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono", + "account_basics_phone_numbers_dialog_number_label": "Número de teléfono", + "account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Llámame", + "account_basics_phone_numbers_dialog_code_label": "Código de verificación", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Llamar", + "account_usage_calls_title": "Llamadas telefónicas realizadas", + "account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta", + "account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias", + "account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias", + "account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas", + "publish_dialog_call_reset": "Eliminar llamada telefónica", + "account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.", + "account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles", + "account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código", + "account_basics_phone_numbers_dialog_title": "Agregar número de teléfono", + "account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456", + "publish_dialog_call_item": "Llamar al número de teléfono {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados" } diff --git a/web/public/static/langs/fi.json b/web/public/static/langs/fi.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/fi.json @@ -0,0 +1 @@ +{} diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index ecb81f8a..096b62af 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -7,7 +7,7 @@ "message_bar_type_message": "Tapez un message ici", "notifications_attachment_open_button": "Ouvrir la pièce jointe", "notifications_attachment_link_expires": "le lien expire {{date}}", - "message_bar_error_publishing": "Notification d'erreur de publication", + "message_bar_error_publishing": "Erreur lors de la publication de la notification", "nav_button_all_notifications": "Toutes les notifications", "nav_button_settings": "Paramètres", "nav_button_documentation": "Documentation", @@ -50,24 +50,24 @@ "publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}", "nav_button_subscribe": "S'abonner au sujet", "notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.", - "alert_grant_title": "Les notifications sont désactivées", - "alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.", - "alert_grant_button": "Accorder maintenant", + "alert_notification_permission_required_title": "Les notifications sont désactivées", + "alert_notification_permission_required_description": "Autorisez votre navigateur à afficher les notifications du bureau.", + "alert_notification_permission_required_button": "Accorder maintenant", "notifications_none_for_any_title": "Vous n'avez reçu aucune notification.", "publish_dialog_title_topic": "Publier vers {{topic}}", "publish_dialog_title_no_topic": "Publier la notification", "notifications_more_details": "Pour plus d'information, visitez le site web ou la documentation.", "publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque", "publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts", - "publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)", + "publish_dialog_delay_placeholder": "Délai de réception, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)", "publish_dialog_other_features": "Autres fonctionnalités :", "notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web", "notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}", "publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants", "publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1", "publish_dialog_priority_label": "Priorité", - "publish_dialog_click_label": "Cliquer sur l'URL", - "publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée", + "publish_dialog_click_label": "URL du clic", + "publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification", "publish_dialog_attach_label": "URL de la pièce jointe", "publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk", "publish_dialog_filename_label": "Nom du fichier", @@ -79,8 +79,8 @@ "subscribe_dialog_subscribe_title": "S'abonner au sujet", "subscribe_dialog_login_title": "Connexion nécessaire", "prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus", - "prefs_users_dialog_button_cancel": "Annuler", - "error_boundary_button_copy_stack_trace": "Copier la stack strace", + "common_cancel": "Annuler", + "error_boundary_button_copy_stack_trace": "Copier la trace d'appels", "publish_dialog_attached_file_title": "Fichier joint :", "publish_dialog_checkbox_publish_another": "Publier un autre", "publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint", @@ -106,7 +106,7 @@ "prefs_notifications_title": "Notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_users_add_button": "Ajouter un utilisateur", - "subscribe_dialog_login_button_back": "Retour", + "common_back": "Retour", "subscribe_dialog_error_user_anonymous": "anonyme", "prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_min_priority_title": "Priorité minimum", @@ -128,10 +128,10 @@ "prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.", "prefs_users_table_user_header": "Utilisateur", "prefs_users_dialog_title_edit": "Éditer l'utilisateur", - "prefs_users_dialog_button_add": "Ajouter", - "error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.
Si vous avez une minute, merci de signaler ceci sur GitHub, ou faites-le nous savoir par Discord ou Matric.", + "common_add": "Ajouter", + "error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.
Si vous avez une minute, merci de signaler ceci sur GitHub, ou faites-le nous savoir par Discord ou Matrix.", "prefs_users_dialog_title_add": "Ajouter un utilisateur", - "error_boundary_stack_trace": "Stack trace", + "error_boundary_stack_trace": "Trace de pile d'appels", "error_boundary_gathering_info": "Récupérer plus d'information…", "prefs_notifications_delete_after_one_week": "Après une semaine", "prefs_notifications_delete_after_one_month": "Après un mois", @@ -152,5 +152,233 @@ "publish_dialog_chip_topic_label": "Changer de sujet", "publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la documentation.", "publish_dialog_button_cancel_sending": "Annuler l'envoi", - "prefs_users_dialog_button_save": "Enregistrer" + "common_save": "Enregistrer", + "notifications_new_indicator": "Nouvelle notification", + "publish_dialog_delay_reset": "Retirer le délai de réception", + "notifications_list_item": "Notification", + "notifications_priority_x": "Priorité {{priority}}", + "notifications_mark_read": "Marquer comme lu", + "notifications_attachment_image": "Image jointe", + "notifications_delete": "Supprimer", + "notifications_attachment_file_video": "fichier vidéo", + "notifications_attachment_file_audio": "fichier audio", + "prefs_users_table": "Liste des utilisateurs", + "notifications_attachment_file_image": "fichier image", + "notifications_attachment_file_app": "fichier d'application Android", + "notifications_attachment_file_document": "autre document", + "prefs_notifications_sound_play": "Jouer le son sélectionné", + "error_boundary_unsupported_indexeddb_description": "L'application web ntfy a besoin d'IndexedDB pour fonctionner, mais votre navigateur ne supporte pas IndexedDB en navigation privée.

Bien que cela soit regrettable, il serait peu utile d'utiliser l'application web ntfy en navigation privée, car tout est stocké par votre navigateur. Vous pouvez vous renseigner plus amplement à ce propos dans ce ticket GitHub, ou en parler avec nous sur Discord ou Matrix.", + "action_bar_show_menu": "Montrer le menu", + "action_bar_toggle_mute": "Mettre en sourdine/réactiver les notifications", + "action_bar_toggle_action_menu": "Ouvrir/fermer le menu d'actions", + "publish_dialog_emoji_picker_show": "Choisir un emoji", + "publish_dialog_topic_reset": "Réinitialiser le sujet", + "message_bar_publish": "Publier le message", + "nav_button_muted": "Notifications en sourdine", + "nav_button_connecting": "connexion en cours", + "notifications_list": "Liste des notifications", + "message_bar_show_dialog": "Montrer le formulaire de publication", + "action_bar_logo_alt": "Logo de ntfy", + "publish_dialog_click_reset": "Retirer l'URL du clic", + "publish_dialog_email_reset": "Retirer le transfert par courriel", + "publish_dialog_attach_reset": "Retirer l'URL de la pièce jointe", + "emoji_picker_search_clear": "Effacer la recherche", + "subscribe_dialog_subscribe_base_url_label": "URL du service", + "prefs_users_edit_button": "Éditer l'utilisateur", + "prefs_users_delete_button": "Supprimer l'utilisateur", + "error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge", + "publish_dialog_attached_file_remove": "Retirer le fichier joint", + "signup_form_password": "Mot de passe", + "signup_form_confirm_password": "Confirmation du mot de passe", + "signup_disabled": "L'inscription est désactivée", + "signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé", + "signup_error_creation_limit_reached": "Limite de création de comptes atteinte", + "login_title": "Se connecter à son compte Ntfy", + "login_form_button_submit": "Connexion", + "login_link_signup": "S'inscrire", + "login_disabled": "La connection est désactivée", + "action_bar_account": "Compte", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Paramètres", + "action_bar_sign_in": "Connexion", + "action_bar_sign_up": "Inscription", + "nav_button_account": "Compte", + "signup_title": "Créer un compte Ntfy", + "signup_form_username": "Identifiant", + "signup_form_button_submit": "S'inscrire", + "signup_already_have_account": "Vous avez déjà un compte ? Connectez-vous !", + "action_bar_profile_logout": "Se déconnecter", + "signup_form_toggle_password_visibility": "Afficher le mot de passe", + "action_bar_change_display_name": "Changer le nom affiché", + "prefs_reservations_table_click_to_subscribe": "Cliquer pour s'abonner", + "account_tokens_table_cannot_delete_or_edit": "Impossible d'éditer ou de supprimer le jeton de la session actuelle", + "account_tokens_dialog_button_cancel": "Annuler", + "prefs_users_table_cannot_delete_or_edit": "Impossible de supprimer ou de modifier un utilisateur connecté", + "prefs_users_description_no_sync": "Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.", + "account_tokens_dialog_button_update": "Mettre à jour un jeton", + "nav_upgrade_banner_description": "Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges", + "display_name_dialog_description": "Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.", + "account_usage_basis_ip_description": "Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.", + "action_bar_reservation_add": "Réserver un sujet", + "action_bar_reservation_edit": "Changer la réservation", + "action_bar_reservation_delete": "Supprimer la réservation", + "action_bar_reservation_limit_reached": "Limite atteinte", + "nav_upgrade_banner_label": "Passer à ntfy Pro", + "display_name_dialog_title": "Changer le nom affiché", + "reserve_dialog_checkbox_label": "Réserver un sujet et en configurer l'accès", + "display_name_dialog_placeholder": "Nom affiché", + "subscribe_dialog_subscribe_button_generate_topic_name": "Générer un nom", + "subscribe_dialog_error_topic_already_reserved": "Sujet déjà réservé", + "account_basics_title": "Compte", + "account_basics_username_title": "Nom d'utilisateur", + "account_basics_username_description": "Hé, c'est toi ❤", + "account_basics_username_admin_tooltip": "Vous êtes Administrateur", + "account_basics_password_title": "Mot de passe", + "account_basics_password_description": "Changer le mot de passe de votre compte", + "account_basics_password_dialog_title": "Changer le mot de passe", + "account_basics_password_dialog_current_password_label": "Mot de passe actuel", + "account_basics_password_dialog_new_password_label": "Nouveau mot de passe", + "account_basics_password_dialog_confirm_password_label": "Confirmer le mot de passe", + "account_basics_password_dialog_button_submit": "Changer le mot de passe", + "account_basics_password_dialog_current_password_incorrect": "Mot de passe incorrect", + "account_usage_title": "Utilisation", + "account_usage_of_limit": "sur {{limit}}", + "account_usage_unlimited": "Illimité", + "account_usage_limits_reset_daily": "Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)", + "account_basics_tier_title": "Type de compte", + "account_basics_tier_description": "Le niveau de puissance de votre compte", + "account_basics_tier_admin": "Administrateur", + "account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(pas de tarif)", + "account_basics_tier_free": "Gratuit", + "account_basics_tier_upgrade_button": "Passer à Pro", + "account_basics_tier_change_button": "Changer", + "account_basics_tier_paid_until": "Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé", + "account_basics_tier_canceled_subscription": "Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.", + "account_basics_tier_manage_billing_button": "Gérer la facturation", + "account_usage_messages_title": "Messages publiés", + "account_usage_emails_title": "Emails envoyés", + "account_usage_reservations_title": "Sujets réservés", + "account_usage_reservations_none": "Pas de sujet réservé pour ce compte", + "account_usage_attachment_storage_title": "Stockage des pièces jointes", + "account_usage_attachment_storage_description": "{{filesize}} par fichier, supprimé après {{expiry}}", + "account_usage_cannot_create_portal_session": "Impossible d'ouvrir le portail de facturation", + "account_delete_title": "Supprimer le compte", + "account_delete_description": "Supprimer définitivement votre compte", + "account_basics_tier_basic": "Basique", + "account_delete_dialog_description": "Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.", + "account_delete_dialog_label": "Mot de passe", + "account_delete_dialog_button_cancel": "Annuler", + "account_delete_dialog_button_submit": "Supprimer définitivement le compte", + "account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.", + "account_upgrade_dialog_title": "Changer le tarif du compte", + "account_upgrade_dialog_proration_info": "Facturation : Lors d'un changement vers un tiers payant, la différence de prix sera débitée immédiatement. En passant d'un tiers payant a gratuit, votre solde sera utilisé pour payer de futur factures.", + "account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, veuillez supprimer au moins {{count}} sujets réservés. Vous pouvez supprimer des sujets réservés dans les Paramètres.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} sujets réservés", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} messages journaliers", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} emails journaliers", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total", + "account_upgrade_dialog_tier_selected_label": "Sélectionné", + "account_upgrade_dialog_tier_current_label": "Actuel", + "account_upgrade_dialog_button_cancel": "Annuler", + "account_upgrade_dialog_button_redirect_signup": "S'inscrire maintenant", + "account_upgrade_dialog_button_pay_now": "Payer maintenant et s'abonner", + "account_upgrade_dialog_button_cancel_subscription": "Annuler l'abonnement", + "account_upgrade_dialog_button_update_subscription": "Mettre à jour l'abonnement", + "account_tokens_title": "Jetons d'accès", + "account_tokens_table_token_header": "Jeton", + "account_tokens_table_label_header": "Étiquette", + "account_tokens_table_last_access_header": "Dernier accès", + "account_tokens_table_expires_header": "Expire", + "account_tokens_table_never_expires": "N'expire jamais", + "account_tokens_table_current_session": "Session de navigation actuelle", + "common_copy_to_clipboard": "Copier dans le presse-papier", + "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié", + "account_tokens_table_create_token_button": "Créer un jeton d'accès", + "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", + "account_tokens_dialog_title_create": "Créer un jeton d'accès", + "account_tokens_dialog_title_edit": "Modifier le jeton d'accès", + "account_tokens_dialog_title_delete": "Supprimer le jeton d'accès", + "account_tokens_dialog_label": "Étiquette, par ex. Notifications Radarr", + "account_tokens_dialog_button_create": "Créer un jeton", + "account_tokens_dialog_expires_label": "Le jeton d'accès expire dans", + "account_tokens_dialog_expires_unchanged": "Laisser la date d'expiration inchangée", + "account_tokens_dialog_expires_x_hours": "Le jeton expire dans {{hours}} heures", + "account_tokens_dialog_expires_x_days": "Le jeton expire dans {{days}} jours", + "account_tokens_dialog_expires_never": "Le jeton n'expire jamais", + "account_tokens_delete_dialog_title": "Supprimer le jeton d'accès", + "account_tokens_delete_dialog_submit_button": "Supprimer définitivement le jeton", + "prefs_reservations_title": "Sujets réservés", + "prefs_reservations_limit_reached": "Vous avez atteint votre limite de réservation de sujets.", + "prefs_reservations_add_button": "Ajouter un sujet réservé", + "prefs_reservations_edit_button": "Modifier l'accès d'un sujet", + "prefs_reservations_delete_button": "Réinitialiser l'accès d'un sujet", + "prefs_reservations_table": "Tableau des sujets réservés", + "prefs_reservations_table_topic_header": "Sujet", + "prefs_reservations_table_access_header": "Accès", + "prefs_reservations_table_everyone_deny_all": "Seulement moi peut publier et m'abonner", + "prefs_reservations_table_everyone_read_only": "Je peux publier et m'abonner, tout le monde peut s'abonner", + "prefs_reservations_table_everyone_write_only": "Je peux publier et m'abonner, tout le monde peut publier", + "prefs_reservations_table_everyone_read_write": "Tout le monde peut publier et s'abonner", + "prefs_reservations_table_not_subscribed": "Pas abonné", + "prefs_reservations_dialog_title_add": "Réserver un sujet", + "prefs_reservations_dialog_title_edit": "Modifier un sujet réservé", + "prefs_reservations_dialog_title_delete": "Supprimé un sujet réservé", + "prefs_reservations_dialog_description": "Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.", + "prefs_reservations_dialog_topic_label": "Sujet", + "prefs_reservations_dialog_access_label": "Accès", + "reservation_delete_dialog_description": "Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.", + "reservation_delete_dialog_action_keep_title": "Garder les messages et pièces jointes mises en cache", + "reservation_delete_dialog_action_keep_description": "Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.", + "reservation_delete_dialog_action_delete_title": "Supprimer les messages et pièces jointes mises en cache", + "reservation_delete_dialog_action_delete_description": "Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.", + "reservation_delete_dialog_submit_button": "Supprimer un sujet réservé", + "alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la Notifications API.", + "account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.", + "account_upgrade_dialog_cancel_warning": "Cela va annuler votre abonnement et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur seront supprimés.", + "account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, veuillez supprimer au moins un sujet réservé. Vous pouvez supprimer des sujets réservés dans les Paramètres.", + "account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la documentation pour en savoir plus.", + "account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. Cette action ne peut pas être annulée.", + "prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.", + "account_basics_tier_interval_yearly": "annuel", + "account_upgrade_dialog_interval_yearly": "Annuel", + "account_upgrade_dialog_interval_yearly_discount_save": "économisez {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "Aucun sujet(s) réservé(s)", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} par an. Prélevé mensuellement.", + "account_upgrade_dialog_billing_contact_website": "Pour des questions en rapport avec la facturation, se référer à notre site internet.", + "account_basics_tier_interval_monthly": "mensuel", + "account_upgrade_dialog_interval_monthly": "Mensuel", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%", + "account_upgrade_dialog_tier_price_per_month": "mois", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.", + "account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous contacter directement.", + "publish_dialog_call_label": "Appel téléphonique", + "account_basics_phone_numbers_title": "Numéros de téléphone", + "account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.", + "account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques", + "account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone", + "account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier", + "account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone", + "account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone", + "account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304", + "account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi", + "account_basics_phone_numbers_dialog_code_label": "Code de vérification", + "account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Appeler", + "account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte", + "publish_dialog_call_reset": "Supprimer les appels téléphoniques", + "publish_dialog_chip_call_label": "Appel téléphonique", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} message journalier", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} mail journalier", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} appels journaliers", + "account_upgrade_dialog_tier_features_no_calls": "Aucun appel", + "publish_dialog_call_item": "Appeler le numéro {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Aucun numéro de téléphone vérifié", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} sujet réservé", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} appels journaliers", + "account_usage_calls_title": "Appels téléphoniques passés" } diff --git a/web/public/static/langs/gl.json b/web/public/static/langs/gl.json new file mode 100644 index 00000000..92d35610 --- /dev/null +++ b/web/public/static/langs/gl.json @@ -0,0 +1,384 @@ +{ + "common_cancel": "Cancelar", + "common_save": "Gardar", + "common_add": "Engadir", + "signup_disabled": "O rexistro está desactivado", + "signup_error_username_taken": "O identificador {{username}} xa está collido", + "login_title": "Accede á túa conta ntfy", + "action_bar_send_test_notification": "Enviar notificación de proba", + "action_bar_clear_notifications": "Limpar todas as notificacións", + "action_bar_unsubscribe": "Retirar subscrición", + "action_bar_profile_settings": "Axustes", + "message_bar_type_message": "Escribe aquí a mensaxe", + "notifications_copied_to_clipboard": "Copiada ao portapapeis", + "notifications_attachment_image": "Imaxe anexa", + "notifications_attachment_copy_url_title": "Copiar URL do anexo ao portapapeis", + "notifications_attachment_copy_url_button": "Copiar URL", + "notifications_attachment_open_title": "Ir a {{url}}", + "notifications_attachment_file_audio": "ficheiro de audio", + "notifications_attachment_file_app": "ficheiro de app Android", + "notifications_attachment_file_document": "outro documento", + "notifications_click_copy_url_title": "Copiar URL da ligazón ao portapapeis", + "notifications_click_copy_url_button": "Copiar ligazón", + "notifications_actions_open_url_title": "Ir a {{url}}", + "notifications_none_for_topic_description": "Para enviar notificacións a este tema, simplemente usa PUT ou POST co URL do tema.", + "notifications_no_subscriptions_description": "Preme en \"{{linktext}} para crear ou subscribirte a un tema. Após, podes enviar mensaxes vía PUT ou POST e recibirás aquí as notificacións.", + "display_name_dialog_description": "Establecer un nome alternativo para o tema que será mostrado na lista de subscrición. Isto axudará a identificar os temas que teñan nomes complicados.", + "publish_dialog_tags_label": "Etiquetas", + "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por vírgulas, ex. aviso, tarefa1", + "publish_dialog_priority_label": "Prioridade", + "publish_dialog_click_label": "URL a premer", + "publish_dialog_click_placeholder": "URL que se abre ao premer na notificación", + "publish_dialog_click_reset": "Desbotar o URL a premer", + "common_back": "Atrás", + "common_copy_to_clipboard": "Copiar ao portapapeis", + "signup_title": "Crear unha conta ntfy", + "signup_form_username": "Identificador", + "signup_form_password": "Contrasinal", + "signup_form_confirm_password": "Confirmar contrasinal", + "signup_form_button_submit": "Crear conta", + "login_form_button_submit": "Acceder", + "login_link_signup": "Crear conta", + "login_disabled": "O acceso está desactivado", + "action_bar_show_menu": "Mostrar menú", + "action_bar_toggle_mute": "Acalar/Reactivar as notificacións", + "message_bar_error_publishing": "Erro ao publicar a notificación", + "message_bar_publish": "Publicar mensaxe", + "nav_topics_title": "Temas subscritos", + "nav_button_documentation": "Documentación", + "nav_button_publish_message": "Publicar notificación", + "nav_button_subscribe": "Subscribirse ao tema", + "nav_button_muted": "Notificacións acaladas", + "nav_button_connecting": "conectando", + "nav_upgrade_banner_label": "Mellorar a ntfy Pro", + "alert_not_supported_description": "O teu navegador non ten soporte para notificacións.", + "notifications_priority_x": "Prioridade {{priority}}", + "notifications_attachment_link_expires": "a ligazón caduca o {{date}}", + "notifications_attachment_link_expired": "a ligazón de descarga caducou", + "notifications_attachment_file_image": "ficheiro de imaxe", + "notifications_attachment_file_video": "ficheiro de vídeo", + "notifications_actions_not_supported": "Acción non soportada na aplicación web", + "notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}", + "notifications_none_for_topic_title": "Aínda non recibiches ningunha notificación para este tema.", + "reserve_dialog_checkbox_label": "Reservar tema e configurar acceso", + "notifications_loading": "Cargando notificacións…", + "publish_dialog_base_url_placeholder": "URL de servizo, ex. https://exemplo.com", + "publish_dialog_topic_label": "Nome do tema", + "publish_dialog_topic_placeholder": "Nome do tema, ex. alertas_equipo", + "publish_dialog_topic_reset": "Restablecer tema", + "publish_dialog_title_label": "Título", + "publish_dialog_title_placeholder": "Título das notificacións, ex. Alerta de reunión", + "publish_dialog_message_label": "Mensaxe", + "publish_dialog_message_placeholder": "Escribe aquí a mensaxe", + "publish_dialog_email_label": "Correo electrónico", + "signup_form_toggle_password_visibility": "Cambiar visibilidade do contrasinal", + "signup_already_have_account": "Xa tes unha conta? Accede!", + "signup_error_creation_limit_reached": "Acadouse o límite de creación de contas", + "action_bar_logo_alt": "logo ntfy", + "action_bar_settings": "Axustes", + "action_bar_account": "Conta", + "action_bar_change_display_name": "Cambiar nome público", + "action_bar_reservation_add": "Reservar tema", + "action_bar_reservation_edit": "Cambiar a reserva", + "action_bar_reservation_delete": "Desbotar a reserva", + "action_bar_reservation_limit_reached": "Acadouse o límite", + "action_bar_toggle_action_menu": "Abrir/Pechar menú de accións", + "action_bar_profile_title": "Perfil", + "action_bar_profile_logout": "Pechar sesión", + "action_bar_sign_in": "Acceder", + "action_bar_sign_up": "Crear conta", + "message_bar_show_dialog": "Mostrar diálogo para publicar", + "nav_button_all_notifications": "Todas as notificacións", + "nav_button_account": "Conta", + "nav_button_settings": "Axustes", + "nav_upgrade_banner_description": "Reserva temas, máis mensaxes e correos electrónicos así como anexos máis grandes", + "alert_grant_title": "As notificacións están desactivadas", + "alert_grant_description": "Concede permiso no navegador para mostrar notificacións de escritorio.", + "alert_grant_button": "Conceder agora", + "alert_not_supported_title": "Non hai soporte para notificacións", + "alert_not_supported_context_description": "Só hai soporte para notificacións ao usar HTTPS. Esta é unha limitación da API de Notificacións.", + "notifications_list": "Lista de notificacións", + "notifications_list_item": "Notificación", + "notifications_mark_read": "Marcar como lida", + "notifications_delete": "Eliminar", + "notifications_tags": "Etiquetas", + "notifications_new_indicator": "Nova notificación", + "notifications_attachment_open_button": "Abrir anexo", + "notifications_click_open_button": "Abrir ligazón", + "notifications_none_for_any_title": "Non recibiches ningunha notificación.", + "notifications_none_for_any_description": "Para enviar notificacións ao tema, simplemente usa PUT ou POST ao URL do tema. Aquí tes un exemplo usando un dos teus temas.", + "notifications_no_subscriptions_title": "Semella que aínda non tes subscricións.", + "notifications_example": "Exemplo", + "display_name_dialog_title": "Cambiar nonme público", + "display_name_dialog_placeholder": "Nome público", + "publish_dialog_title_topic": "Publicar en {{topic}}", + "publish_dialog_title_no_topic": "Publicar notificación", + "publish_dialog_progress_uploading": "Enviando…", + "publish_dialog_progress_uploading_detail": "Enviando {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Notificación publicada", + "publish_dialog_attachment_limits_file_and_quota_reached": "supera o límite de ficheiros e cota {{fileSizeLimit}}, quedan {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "supera o límite para ficheiros {{fileSizeLimit}}", + "publish_dialog_attachment_limits_quota_reached": "supera a cota, quedan {{remainingBytes}}", + "publish_dialog_emoji_picker_show": "Elixe emoji", + "publish_dialog_priority_min": "Prioridade Mínima", + "publish_dialog_priority_low": "Prioridade baixa", + "publish_dialog_priority_default": "Prioridade por defecto", + "publish_dialog_priority_high": "Prioridade alta", + "publish_dialog_priority_max": "Prioridade Máxima", + "publish_dialog_base_url_label": "URL do servizo", + "notifications_more_details": "Para máis información, visita o sitio web ou le a documentación.", + "publish_dialog_call_label": "Chamada de teléfono", + "publish_dialog_call_reset": "Retirar chamada de teléfono", + "publish_dialog_delay_placeholder": "Adiar a entrega, ex. {{unixTimestamp}}, {{relativeTime}}, ou \"{{naturalLanguage}}\" (Só en inglés)", + "publish_dialog_other_features": "Outras características:", + "publish_dialog_chip_click_label": "Premer en URL", + "publish_dialog_chip_email_label": "Reenvío por correo", + "publish_dialog_chip_call_label": "Chamada de teléfono", + "publish_dialog_chip_attach_url_label": "Anexar ficheiro por URL", + "publish_dialog_button_cancel_sending": "Cancelar o envío", + "publish_dialog_button_cancel": "Cancelar", + "publish_dialog_button_send": "Enviar", + "publish_dialog_attached_file_title": "Ficheiro anexo:", + "publish_dialog_attached_file_filename_placeholder": "Nome do ficheiro anexo", + "publish_dialog_drop_file_here": "Soltar aquí o ficheiro", + "emoji_picker_search_placeholder": "Buscar emoji", + "subscribe_dialog_subscribe_title": "Subscribirse a un tema", + "publish_dialog_call_item": "Número de teléfono {{number}}", + "publish_dialog_email_placeholder": "Enderezo ao que reenviar a notificación, ex. xoana@exemplo.com", + "publish_dialog_email_reset": "Retirar reenvío ao correo", + "publish_dialog_attach_label": "URL do anexo", + "publish_dialog_attach_placeholder": "Anexa un ficheiro por URL, ex. https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Retirar URL do anexo", + "publish_dialog_filename_placeholder": "Nome do ficheiro anexo", + "publish_dialog_filename_label": "Nome do ficheiro", + "publish_dialog_delay_label": "Adiar", + "publish_dialog_delay_reset": "Retirar o adiadamento da entrega", + "publish_dialog_chip_attach_file_label": "Anexar ficheiro local", + "publish_dialog_chip_delay_label": "Entrega adiada", + "publish_dialog_chip_topic_label": "Cambiar tema", + "publish_dialog_details_examples_description": "Para ver exemplos e unha descrición polo miúdo das ferramentas de envío, le a documentación.", + "publish_dialog_checkbox_publish_another": "Publicar outra", + "emoji_picker_search_clear": "Limpar busca", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Números de teléfono non verificados", + "publish_dialog_attached_file_remove": "Retirar ficheiro anexo", + "account_upgrade_dialog_tier_features_no_calls": "Sen chamadas", + "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre pagamentos, contacta con nós directamente.", + "account_tokens_dialog_title_create": "Crear token de acceso", + "prefs_reservations_dialog_title_edit": "Editar tema reservado", + "priority_default": "por defecto", + "prefs_notifications_min_priority_title": "Prioridade mínima", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} chamadas de teléfono diarias", + "account_upgrade_dialog_tier_current_label": "Actual", + "account_tokens_table_token_header": "Token", + "prefs_notifications_delete_after_never": "Nunca", + "prefs_users_description": "Engadir/eliminar usuarias dos temas protexidos. Ten en conta que as credenciais gárdanse na almacenaxe local do navegador.", + "subscribe_dialog_subscribe_description": "Os temas poderían non estar proxetidos con contrasinal, así que elixe un nome complicado de adiviñar. Unha vez subscrita, podes PUT/POST notificacións.", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "aforro ata un {{discount}}%", + "account_tokens_dialog_label": "Etiqueta, ex. notificación de Radarr", + "account_tokens_table_expires_header": "Caducidade", + "account_upgrade_dialog_proration_info": "Axuste: ao mellorar a un plan de pagamento superior, a diferencia vaise cobrar inmediatamente. Se degradas a conta a un plan inferior a diferencia usarase para pagar futuros períodos de pagamento.", + "prefs_reservations_dialog_access_label": "Acceso", + "account_usage_attachment_storage_title": "Almacenaxe dos anexos", + "prefs_users_dialog_username_label": "Identificador, ex. xoana", + "prefs_reservations_table_not_subscribed": "Non subscrita", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} correos diarios", + "prefs_notifications_min_priority_max_only": "Só prioridade máxima", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} chamadas de teléfono diarias", + "prefs_notifications_sound_description_some": "As notificacións sonan co ton {{sound}} ao chegar", + "prefs_reservations_edit_button": "Editar acceso ao tema", + "account_tokens_dialog_expires_never": "O token non caduca", + "subscribe_dialog_login_title": "Require inciar sesión", + "account_tokens_dialog_expires_x_days": "O token caduca en {{days}} días", + "prefs_reservations_table_everyone_read_only": "Podo publicar e subscribirme, calquera pode subscribirse", + "prefs_reservations_table_everyone_deny_all": "Só eu podo publicar e subscribirme", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado", + "subscribe_dialog_login_button_login": "Acceder", + "account_upgrade_dialog_tier_features_no_reservations": "Sen temas reservados", + "prefs_users_table_cannot_delete_or_edit": "Non se pode eliminar ou editar unha usuaria coa sesión iniciada", + "prefs_notifications_delete_after_three_hours_description": "As notificacións autoelimínanse após tres horas", + "prefs_notifications_delete_after_three_hours": "Após tres horas", + "prefs_notifications_min_priority_description_x_or_higher": "Mostrar as notificacións se a prioridade é {{number}} {{name}} ou superior", + "reservation_delete_dialog_description": "Ao eliminar a reserva cedes a propiedade do tema, e permites que outras persoas poidan reservalo. Podes manter ou eliminar as mensaxes e anexos existentes.", + "prefs_reservations_table_everyone_read_write": "Calquera pode publicar e subscribirse", + "prefs_reservations_dialog_title_delete": "Eliminar a reserva do tema", + "prefs_users_table": "Táboa de usuarias", + "prefs_reservations_table_topic_header": "Tema", + "reservation_delete_dialog_submit_button": "Eliminar a reserva", + "prefs_reservations_limit_reached": "Acadaches o límite de temas que podes reservar.", + "account_upgrade_dialog_interval_monthly": "Mensual", + "prefs_users_add_button": "Engadir usuaria", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensaxes diarias", + "prefs_appearance_language_title": "Idioma", + "prefs_notifications_delete_after_one_day_description": "As notificacións autoelimínanse após un día", + "account_tokens_table_never_expires": "Non caduca", + "account_tokens_delete_dialog_title": "Desbotar token de acceso", + "prefs_notifications_delete_after_one_month": "Após un mes", + "account_tokens_delete_dialog_description": "Antes de borrar o token de acceso mira que ningunha aplicación ou programa o está usando. Esta acción non pode desfacerse.", + "account_upgrade_dialog_button_cancel": "Cancelar", + "account_tokens_table_label_header": "Etiqueta", + "account_upgrade_dialog_billing_contact_website": "Para preguntas sobre pagamentos, vai ao noso sitiio web.", + "prefs_notifications_delete_after_never_description": "As notificacións non se eliminarán nunca automáticamente", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} temas reservados", + "prefs_notifications_sound_description_none": "As notificacións non reproducen un ton ao chegar", + "account_tokens_description": "Usar tokens de acceso ao publicar e subscribirte a través da API de ntfy, así non tes que enviar as credenciais. Le a documentación para saber máis.", + "prefs_reservations_table": "Táboa cos temas reservados", + "account_upgrade_dialog_button_cancel_subscription": "Cancelar subscrición", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo diario", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por ficheiro", + "prefs_reservations_description": "Podes reservar nomes de temas para uso personal. Ao reservar un tema tes a propiedade sobre del, e permíteche definir os permisos de acceso para outras usuarias sobre o tema.", + "prefs_users_description_no_sync": "Usuarias e contrasinais non están sincronizados coa túa conta.", + "account_tokens_dialog_title_edit": "Editar token de acceso", + "prefs_users_table_base_url_header": "URL do servizo", + "account_upgrade_dialog_tier_features_messages_one": "{{mensaxes}} mensaxe diaria", + "account_upgrade_dialog_reservations_warning_one": "O nivel seleccionado permite reservar menos temas que o nivel actual. Antes de cambiar de nivel, elimina unha reserva polo menos. Podes eliminar as reservas nos Axustes.", + "prefs_users_table_user_header": "Usuaria", + "error_boundary_stack_trace": "Trazas do problema", + "prefs_users_dialog_password_label": "Contrasinal", + "prefs_notifications_delete_after_one_week": "Após unha semana", + "prefs_reservations_delete_button": "Restablecer acceso ao tema", + "prefs_notifications_delete_after_one_week_description": "As notificacións autoelimínanse após unha semana", + "error_boundary_unsupported_indexeddb_description": "A app ntfy web precisa a función IndexedDB, e o teu navegador non ten soporte para IndexedDB no modo privado.

Aínda que é unha mágoa, tampouco ten moito senso usar a app ntfy web en modo privado, porque todo se garda na almacenaxe do navegador. Podes aprender máis sobre isto neste tema de GitHub, ou comentarnos o que che parece en Discord ou Matrix.", + "subscribe_dialog_subscribe_button_cancel": "Cancelar", + "account_basics_tier_description": "O nivel da túa conta", + "prefs_reservations_dialog_title_add": "Reservar tema", + "account_upgrade_dialog_cancel_warning": "Isto vai cancelar a túa subscrición, e degradar a túa conta o {{date}}. Nesa data, as reservas de temas así como as mensaxes na caché do servidor van ser eliminadas.", + "prefs_notifications_sound_title": "Ton da notificación", + "prefs_notifications_min_priority_default_and_higher": "Prioridade por defecto e superior", + "prefs_reservations_table_access_header": "Acceso", + "account_tokens_table_copied_to_clipboard": "Copiouse o token de acceso", + "account_tokens_dialog_expires_x_hours": "O token caduca en {{hours}} horas", + "prefs_users_edit_button": "Editar usuaria", + "account_upgrade_dialog_title": "Cambiar facturación da conta", + "priority_low": "baixa", + "prefs_reservations_table_click_to_subscribe": "Preme para subscribirte", + "error_boundary_description": "Isto non debería pasar. Lamentámolo.
Se tes un minuto, informa en GitHub, ou fáinolo saber en Discord ou Matrix.", + "priority_min": "min", + "prefs_notifications_min_priority_description_any": "Mostrar todas as notificacións, obviando a prioridade", + "error_boundary_gathering_info": "Obter máis info…", + "error_boundary_unsupported_indexeddb_title": "Non hai soporte para a navegación privada", + "prefs_notifications_delete_after_one_day": "Após un día", + "error_boundary_title": "vaite!, ntfy fallou", + "reservation_delete_dialog_action_keep_description": "As mensaxes e anexos que están no servidor serán visibles públicamente para quen saiba o nome do tema.", + "prefs_reservations_add_button": "Engadir tema reservado", + "prefs_reservations_title": "Temas reservados", + "prefs_reservations_dialog_description": "Ao reservar un tema tes a propiedade sobre el, e permíteche definir os permisos de acceso para outras usuarias.", + "account_tokens_delete_dialog_submit_button": "Eliminar definitivamente o token", + "prefs_notifications_title": "Notificacións", + "account_tokens_title": "Tokens de acceso", + "prefs_reservations_dialog_topic_label": "Tema", + "prefs_users_title": "Xestionar usuarias", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} anual. Pagamento mensual.", + "account_tokens_dialog_expires_unchanged": "Deixar a data de caducidade sen cambiar", + "error_boundary_button_copy_stack_trace": "Copiar trazas do problema", + "account_tokens_dialog_title_delete": "Eliminar token de acceso", + "reservation_delete_dialog_action_keep_title": "Manter as mensaxes e anexos gardados", + "prefs_notifications_sound_no_sound": "Sen ton", + "account_upgrade_dialog_interval_yearly": "Anual", + "account_upgrade_dialog_button_redirect_signup": "Crea unha conta", + "account_tokens_dialog_button_cancel": "Cancelar", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} cobrado anualmente. Aforro {{save}}.", + "prefs_notifications_min_priority_high_and_higher": "Prioridade alta e superior", + "priority_max": "máx", + "prefs_users_delete_button": "Eliminar usuaria", + "prefs_notifications_min_priority_any": "Calquera prioridade", + "account_tokens_dialog_expires_label": "O token caduca o", + "prefs_notifications_delete_after_title": "Desbotar notificacións", + "account_upgrade_dialog_interval_yearly_discount_save": "aforro {{discount}}%", + "prefs_users_dialog_title_edit": "Editar usuaria", + "prefs_notifications_min_priority_low_and_higher": "Prioridade baixa e superior", + "account_tokens_dialog_button_update": "Actualizar token", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} almacenaxe total", + "prefs_reservations_table_everyone_write_only": "Podo publicar e subscribirme, calquera pode publicar", + "prefs_appearance_title": "Aparencia", + "account_tokens_table_cannot_delete_or_edit": "Non se pode editar ou desbotar o token da sesión actual", + "prefs_notifications_sound_play": "Reproducir ton seleccionado", + "account_tokens_table_last_access_header": "Último acceso", + "account_tokens_table_last_origin_tooltip": "Desde o enderezo IP {{ip}}, preme para detalles", + "account_upgrade_dialog_tier_price_per_month": "mes", + "account_tokens_table_current_session": "Sesión do navegador actual", + "account_upgrade_dialog_button_pay_now": "Paga e subscríbete", + "reservation_delete_dialog_action_delete_title": "Eliminar mensaxes e anexos gardados", + "reservation_delete_dialog_action_delete_description": "As mensaxes e anexos vanse borrar definitivamente. Esta acción non ten volta.", + "prefs_notifications_delete_after_one_month_description": "As notificacións autoelimínanse após un mes", + "prefs_users_dialog_base_url_label": "URL do servizo, ex. https://ntfy.sh", + "account_upgrade_dialog_tier_selected_label": "Seleccionado", + "account_upgrade_dialog_button_update_subscription": "Actualizar subscrición", + "priority_high": "alta", + "account_delete_dialog_billing_warning": "Ao eliminar a conta tamén cancelas o pagamento das subscricións. Non poderás volver acceder ao taboleiro de pagamentos.", + "prefs_notifications_min_priority_description_max": "Mostrar notificacións se a prioridade é 5 (máx)", + "account_upgrade_dialog_reservations_warning_other": "O nivel seleccionado permite reservar menos temas que o nivel actual. Antes de cambiar de nivel, elimina {{count}} reservas polo menos. Podes eliminar as reservas nos Axustes.", + "prefs_users_dialog_title_add": "Engadir usuaria", + "account_tokens_dialog_button_create": "Crear token", + "account_tokens_table_create_token_button": "Crear token de acceso", + "account_basics_tier_interval_monthly": "mensual", + "account_basics_tier_canceled_subscription": "A sua suscripción foi cancelada e vostede será degradado a unha conta gratuita o {{date}}.", + "account_basics_password_dialog_current_password_incorrect": "Contrasinal incorrecto", + "account_basics_phone_numbers_dialog_number_label": "Número de teléfono", + "account_basics_password_dialog_button_submit": "Modificar contrasinal", + "account_basics_username_title": "Usuario", + "account_basics_phone_numbers_dialog_check_verification_button": "Código de confirmación", + "account_usage_messages_title": "Mesaxes publicados", + "account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS", + "account_basics_tier_change_button": "Cambiar", + "account_basics_phone_numbers_dialog_description": "Para usar a característica de chamadas de teléfono, vostede debe engadir e verificar ao menos un número de teléfono. A verificación pode ser realizada vía SMS ou a través de chamada.", + "account_delete_title": "Borrar conta", + "account_delete_dialog_label": "Contrasinal", + "account_basics_tier_admin_suffix_with_tier": "(con tier {{tier}})", + "subscribe_dialog_login_username_label": "Nome de usuario, ex. phil", + "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} non autorizado", + "account_basics_title": "Conta", + "account_basics_phone_numbers_no_phone_numbers_yet": "Aínda non hay números de teléfono", + "subscribe_dialog_subscribe_button_generate_topic_name": "Xerar nome", + "subscribe_dialog_login_password_label": "Contrasinal", + "subscribe_dialog_subscribe_button_subscribe": "Subscribirse", + "account_basics_phone_numbers_dialog_title": "Engadir número de teléfono", + "account_basics_username_admin_tooltip": "É vostede Admin", + "account_delete_dialog_description": "Isto borrará permanentemente a túa conta, incluido todos os datos almacenados no servidor. Despois do borrado, o teu nome de usuario non estará dispoñible durante 7 días. Se realmente queres proceder, por favor confirme co seu contrasinal na caixa inferior.", + "account_usage_reservations_none": "Non hai temas reservados para esta conta", + "subscribe_dialog_subscribe_topic_placeholder": "Nome do tema, ex. phil_alertas", + "account_usage_title": "Uso", + "account_basics_tier_upgrade_button": "Mexorar a Pro", + "subscribe_dialog_error_topic_already_reserved": "Tema xa reservado", + "account_basics_tier_admin_suffix_no_tier": "(sen tier)", + "account_basics_tier_payment_overdue": "O pago está retrasado. Por favor, revise o seu método de pago o a súa conta será degradada pronto.", + "account_basics_phone_numbers_description": "Para notificacións telefónicas", + "account_basics_tier_free": "De balde", + "account_basics_tier_admin": "Admin", + "account_delete_dialog_button_cancel": "Cancelar", + "account_basics_password_description": "Modificar o contrasinal da conta", + "account_usage_calls_title": "Chamadas realizadas", + "account_basics_tier_basic": "Básico", + "account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado no portapapeis", + "account_basics_tier_title": "Tipo de conta", + "account_usage_cannot_create_portal_session": "Non foi posible abrir o portal de pagos", + "account_delete_description": "Borrar permanentemente a túa conta", + "account_basics_phone_numbers_dialog_number_placeholder": "ex. +1222333444", + "account_basics_phone_numbers_dialog_code_placeholder": "ex. 123456", + "account_basics_tier_manage_billing_button": "Xestionar pagos", + "account_basics_username_description": "Ei, ese eres ti ❤", + "account_basics_password_dialog_confirm_password_label": "Confirmar contrasinal", + "account_basics_tier_interval_yearly": "anual", + "account_delete_dialog_button_submit": "Borrar permanentemente a conta", + "account_basics_phone_numbers_dialog_channel_call": "Chamada", + "account_basics_password_title": "Contrasinal", + "account_basics_password_dialog_new_password_label": "Novo contrasinal", + "account_usage_of_limit": "de {{limit}}", + "subscribe_dialog_error_user_anonymous": "anónimo", + "account_usage_basis_ip_description": "Estadísticas de uso e límites para esta conta están basados na sua IP, polo que poden estar compartidos con outros usuarios. Os limites mostrados son aproximados, basados nos ratios de limite existentes.", + "account_basics_password_dialog_title": "Modificar contrasinal", + "account_usage_limits_reset_daily": "Límite de uso é reiniciado diariamente a medianoite (UTC(", + "account_usage_unlimited": "Sen límites", + "account_basics_phone_numbers_title": "Números de teléfono", + "account_basics_password_dialog_current_password_label": "Contrasinal actual", + "subscribe_dialog_subscribe_base_url_label": "URL do servizo", + "account_usage_reservations_title": "Temas reservados", + "account_usage_calls_none": "Non se poden realizar chamadas con esta conta", + "subscribe_dialog_subscribe_use_another_label": "Usar outro servidor", + "account_basics_phone_numbers_dialog_code_label": "Código de verificación", + "account_basics_tier_paid_until": "Suscripción pagada ata {{date}}, e vaise auto-renovar", + "account_usage_attachment_storage_description": "{{filesize}} por arquivo, borrado despois de {{expiry}}", + "account_basics_phone_numbers_dialog_verify_button_call": "Chámame", + "account_usage_emails_title": "Emails enviados", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse." +} diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json new file mode 100644 index 00000000..e4e0b85b --- /dev/null +++ b/web/public/static/langs/hu.json @@ -0,0 +1,191 @@ +{ + "action_bar_send_test_notification": "Teszt értesítés küldése", + "action_bar_clear_notifications": "Összes értesítés törlése", + "alert_not_supported_description": "A böngésző nem támogatja az értesítések fogadását.", + "action_bar_settings": "Beállítások", + "action_bar_unsubscribe": "Leiratkozás", + "message_bar_type_message": "Írd ide az üzenetet", + "message_bar_error_publishing": "Hiba történt az értesítés elküldése közben", + "nav_button_all_notifications": "Összes értesítés", + "nav_topics_title": "Feliratkozott témák", + "alert_notification_permission_required_title": "Az értesítések le vannak tiltva", + "alert_notification_permission_required_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.", + "nav_button_settings": "Beállítások", + "nav_button_documentation": "Dokumentáció", + "nav_button_publish_message": "Értesítés küldése", + "alert_notification_permission_required_button": "Engedélyezés", + "alert_not_supported_title": "Nem támogatott funkció", + "notifications_copied_to_clipboard": "Másolva a vágólapra", + "notifications_tags": "Címkék", + "notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét", + "notifications_attachment_copy_url_button": "URL másolása", + "notifications_attachment_open_title": "Menjen a(z) {{url}} címre", + "notifications_attachment_open_button": "Csatolmány megnyitása", + "notifications_attachment_link_expired": "A letöltési hivatkozás lejárt", + "notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le", + "nav_button_subscribe": "Feliratkozás témára", + "notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét", + "notifications_actions_open_url_title": "Menjen a(z) {{url}} címre", + "notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban", + "notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre", + "notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.", + "notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.", + "notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.", + "notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.", + "publish_dialog_message_published": "Értesítés elküldve", + "notifications_example": "Példa", + "notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.", + "publish_dialog_priority_low": "Alacsony prioritás", + "publish_dialog_priority_default": "Közepes prioritás", + "publish_dialog_priority_high": "Magas prioritás", + "notifications_more_details": "További információkért keresd fel a weboldalunkat vagy olvasd el a dokumentációt.", + "publish_dialog_title_no_topic": "Értesítés küldése", + "publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)", + "publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt", + "publish_dialog_priority_min": "Legkisebb prioritás", + "publish_dialog_base_url_label": "A szolgáltatás URL-e", + "publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com", + "publish_dialog_topic_label": "Téma neve", + "publish_dialog_priority_max": "Legmagasabb prioritás", + "publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai", + "publish_dialog_title_label": "Cím", + "publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely", + "publish_dialog_message_label": "Üzenet", + "publish_dialog_message_placeholder": "Írj ide egy üzenetet", + "publish_dialog_tags_label": "Címkék", + "publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup", + "publish_dialog_priority_label": "Prioritás", + "publish_dialog_click_label": "URL", + "publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com", + "publish_dialog_attach_label": "Csatolmány URL-e", + "publish_dialog_filename_label": "Fájlnév", + "publish_dialog_filename_placeholder": "Csatolmány fájlneve", + "publish_dialog_delay_label": "Késleltetés", + "publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)", + "publish_dialog_other_features": "Egyéb lehetőségek:", + "publish_dialog_chip_click_label": "Kattintási URL", + "publish_dialog_chip_attach_file_label": "Helyi fájl csatolása", + "publish_dialog_chip_delay_label": "Késleltetett kézbesítés", + "publish_dialog_chip_topic_label": "Téma megváltoztatása", + "publish_dialog_button_cancel_sending": "Küldés megállítása", + "publish_dialog_button_cancel": "Mégsem", + "publish_dialog_checkbox_publish_another": "Küldök még egyet", + "publish_dialog_attached_file_title": "Csatolt fájl:", + "publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve", + "publish_dialog_drop_file_here": "Ejtsd ide a fájlt", + "emoji_picker_search_placeholder": "Emoji keresése", + "publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a dokumentációt.", + "subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert", + "subscribe_dialog_subscribe_button_subscribe": "Feliratkozás", + "subscribe_dialog_login_title": "Be kell jelentkezni", + "subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.", + "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", + "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", + "subscribe_dialog_login_password_label": "Jelszó", + "common_back": "Vissza", + "subscribe_dialog_login_button_login": "Belépés", + "subscribe_dialog_error_user_anonymous": "névtelen", + "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", + "prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül", + "prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg", + "prefs_notifications_min_priority_any": "Bármilyen prioritás", + "prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb", + "prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás", + "prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás", + "prefs_notifications_sound_title": "Értesítés hangja", + "prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek", + "prefs_notifications_sound_no_sound": "Hang nélkül", + "prefs_notifications_delete_after_one_week": "1 hét után", + "prefs_notifications_delete_after_one_month": "1 hónap után", + "prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve", + "prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése", + "prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése", + "prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.", + "prefs_users_table_user_header": "Felhasználó", + "prefs_users_table_base_url_header": "Szerver címe", + "prefs_users_dialog_title_edit": "Felhasználó szerkesztése", + "prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi", + "prefs_users_dialog_password_label": "Jelszó", + "common_add": "Hozzáadás", + "prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh", + "notifications_loading": "Értesítések betöltése …", + "publish_dialog_progress_uploading": "Feltöltés …", + "notifications_click_copy_url_button": "Hivatkozás másolása", + "notifications_click_open_button": "Hivatkozás megnyitása", + "publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …", + "notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.", + "prefs_notifications_delete_after_one_day": "1 nap után", + "publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk", + "publish_dialog_chip_email_label": "Továbbítás email-ben", + "publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel", + "publish_dialog_button_send": "Küldés", + "subscribe_dialog_subscribe_title": "Feliratkozás témára", + "subscribe_dialog_subscribe_button_cancel": "Mégsem", + "prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás", + "prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb", + "prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb", + "prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése", + "prefs_users_add_button": "Felhasználó hozzáadása", + "subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai", + "prefs_notifications_title": "Értesítések", + "error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása", + "prefs_notifications_delete_after_title": "Régi értesítések törlése", + "prefs_notifications_delete_after_three_hours": "3 óra után", + "error_boundary_title": "Jaj ne, az ntfy összeomlott", + "prefs_notifications_delete_after_never": "Soha", + "prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése", + "prefs_appearance_title": "Megjelenés", + "priority_default": "közepes", + "priority_high": "magas", + "priority_max": "legmagasabb", + "priority_min": "legkisebb", + "error_boundary_gathering_info": "Több információ…", + "publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})", + "prefs_users_title": "Felhasználók kezelése", + "common_cancel": "Mégsem", + "common_save": "Mentés", + "prefs_users_dialog_title_add": "Felhasználó hozzáadása", + "prefs_appearance_language_title": "Nyelv", + "priority_low": "alacsony", + "error_boundary_stack_trace": "Verem nyomkövetés", + "publish_dialog_title_topic": "A {{topic}} téma értesítése", + "prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni", + "error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.
Ha van egy perced, jelentsd be GitHubon, vagy tudasd velünk Discordon, vagy Matrixon.", + "action_bar_show_menu": "Menü mutatása", + "action_bar_toggle_mute": "Üzenetek némítása/bekapcsolása", + "notifications_list_item": "Értesítés", + "error_boundary_unsupported_indexeddb_description": "A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.

Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat ezen a Github oldalon, vagy beszéljen velünk Discord-on vagy Matrix-on.", + "notifications_priority_x": "Prioritás {{prioritás}}", + "message_bar_show_dialog": "Küldött üzenetek megjelenítése", + "action_bar_logo_alt": "ntfy logó", + "action_bar_toggle_action_menu": "Tevékenységkezelő nyitása/zárása", + "message_bar_publish": "Üzenet küldése", + "nav_button_muted": "Értesítések némítva", + "nav_button_connecting": "csatlakozás", + "notifications_list": "Értesítés lista", + "notifications_mark_read": "Jelölés olvasottként", + "notifications_delete": "Törlés", + "notifications_new_indicator": "Új értesítés", + "notifications_attachment_image": "Csatolt kép", + "notifications_attachment_file_image": "Kép fájl", + "notifications_attachment_file_video": "Videó fájl", + "notifications_attachment_file_audio": "Hang fájl", + "notifications_attachment_file_app": "Android alkalmazás fájl", + "notifications_attachment_file_document": "egyéb dokumentum", + "publish_dialog_emoji_picker_show": "Emoji kiválasztása", + "publish_dialog_topic_reset": "Téma visszaállítása", + "publish_dialog_click_reset": "URL kattintás törlése", + "publish_dialog_email_reset": "Email továbbítás törlése", + "publish_dialog_attach_reset": "Csatolt URL törlése", + "publish_dialog_delay_reset": "Késleltetett kézbesítés törlése", + "publish_dialog_attached_file_remove": "Csatolt fájl törlése", + "emoji_picker_search_clear": "Keresés törlése", + "prefs_notifications_sound_play": "Kijelölt hang lejátszása", + "prefs_users_table": "Felhasználó táblázat", + "prefs_users_edit_button": "Felhasználó szerkesztése", + "prefs_users_delete_button": "Felhasználó törlése", + "error_boundary_unsupported_indexeddb_title": "Privát böngészés nem támogatott", + "subscribe_dialog_subscribe_base_url_label": "Szolgáltató URL" +} diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 87da7217..b9732bae 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -19,11 +19,11 @@ "publish_dialog_message_label": "Pesan", "nav_button_settings": "Pengaturan", "nav_button_documentation": "Dokumentasi", - "prefs_users_dialog_button_add": "Tambahkan", + "common_add": "Tambahkan", "nav_topics_title": "Topik yang dilanggani", "nav_button_subscribe": "Berlangganan ke topik", - "alert_grant_title": "Notifikasi dinonaktifkan", - "alert_grant_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.", + "alert_notification_permission_required_title": "Notifikasi dinonaktifkan", + "alert_notification_permission_required_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.", "alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda.", "notifications_attachment_open_title": "Pergi ke {{url}}", "notifications_attachment_open_button": "Buka lampiran", @@ -33,7 +33,7 @@ "notifications_click_open_button": "Buka tautan", "publish_dialog_topic_placeholder": "Nama topik, mis. pemberitahuan_andi", "nav_button_publish_message": "Publikasikan notifikasi", - "alert_grant_button": "Berikan sekarang", + "alert_notification_permission_required_button": "Berikan sekarang", "notifications_copied_to_clipboard": "Disalin ke papan klip", "notifications_tags": "Tanda", "notifications_attachment_copy_url_title": "Salin URL lampiran ke papan klip", @@ -113,10 +113,10 @@ "prefs_notifications_sound_no_sound": "Tidak ada suara", "prefs_users_table_user_header": "Pengguna", "prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh", - "prefs_users_dialog_button_save": "Simpan", + "common_save": "Simpan", "prefs_appearance_title": "Tampilan", "subscribe_dialog_login_password_label": "Kata sandi", - "subscribe_dialog_login_button_back": "Kembali", + "common_back": "Kembali", "prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi", "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi", @@ -131,7 +131,7 @@ "prefs_users_dialog_title_add": "Tambahkan pengguna", "prefs_users_dialog_title_edit": "Edit pengguna", "prefs_users_dialog_password_label": "Kata sandi", - "prefs_users_dialog_button_cancel": "Batal", + "common_cancel": "Batal", "error_boundary_title": "Aduh, ntfy mogok", "error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.
Jika Anda punya beberapa menit, silakan laporkan ini di GitHub, atau beritahu kami melalui Discord atau Matrix.", "error_boundary_stack_trace": "Jejak tumpukan", @@ -152,5 +152,234 @@ "priority_default": "bawaan", "priority_min": "min", "notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web", - "notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}" + "notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}", + "action_bar_show_menu": "Tampilkan menu", + "action_bar_logo_alt": "logo ntfy", + "action_bar_toggle_mute": "Bisu/suarakan notifikasi", + "action_bar_toggle_action_menu": "Buka/tutup menu tindakan", + "message_bar_show_dialog": "Tampilkan dialog publikasi", + "message_bar_publish": "Publikasikan pesan", + "nav_button_muted": "Notifikasi dibisukan", + "nav_button_connecting": "menghubungkan", + "notifications_list": "Daftar notifikasi", + "notifications_list_item": "Notifikasi", + "notifications_mark_read": "Tandai sebagai dibaca", + "notifications_delete": "Hapus", + "notifications_priority_x": "Prioritas {{priority}}", + "notifications_new_indicator": "Notifikasi baru", + "notifications_attachment_image": "Lampiran gambar", + "notifications_attachment_file_image": "file gambar", + "notifications_attachment_file_video": "file", + "notifications_attachment_file_audio": "file audio", + "notifications_attachment_file_app": "file aplikasi Android", + "notifications_attachment_file_document": "dokumen lainnya", + "publish_dialog_emoji_picker_show": "Pilih emoji", + "publish_dialog_topic_reset": "Atur ulang topik", + "publish_dialog_click_reset": "Hapus URL klik", + "publish_dialog_email_reset": "Hapus terusan email", + "publish_dialog_attach_reset": "Hapus URL lampiran", + "publish_dialog_delay_reset": "Hapus pengiriman telat", + "publish_dialog_attached_file_remove": "Hapus file yang dilampirkan", + "emoji_picker_search_clear": "Hapus pencarian", + "subscribe_dialog_subscribe_base_url_label": "URL layanan", + "prefs_notifications_sound_play": "Mainkan suara yang dipilih", + "prefs_users_table": "Tabel pengguna", + "prefs_users_edit_button": "Edit pengguna", + "prefs_users_delete_button": "Hapus pengguna", + "error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.

Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya di masalah GitHub ini, atau berbicara dengan kami di Discord atau Matrix.", + "error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung", + "signup_form_confirm_password": "Konfirmasi kata sandi", + "signup_form_button_submit": "Daftar", + "signup_form_toggle_password_visibility": "Alih keterlihatan kata sandi", + "signup_already_have_account": "Sudah punya akun? Masuk!", + "signup_disabled": "Pendaftaran dinonaktifkan", + "signup_error_username_taken": "Nama pengguna {{username}} telah digunakan", + "signup_error_creation_limit_reached": "Batasan pembuatan akun tercapai", + "login_title": "Masuk ke akun ntfy Anda", + "login_disabled": "Pemasukan dinonaktifkan", + "action_bar_account": "Akun", + "action_bar_change_display_name": "Ubah nama tampilan", + "action_bar_reservation_add": "Reservasi topik", + "action_bar_reservation_edit": "Ubah reservasi", + "action_bar_reservation_delete": "Hapus reservasi", + "action_bar_reservation_limit_reached": "Batasan tercapai", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Pengaturan", + "action_bar_profile_logout": "Keluar", + "nav_button_account": "Akun", + "display_name_dialog_placeholder": "Nama tampilan", + "reserve_dialog_checkbox_label": "Reservasi topik dan atur akses", + "nav_upgrade_banner_description": "Reservasikan topik, lebih banyak pesan & surel, dan lampiran lebih besar", + "signup_title": "Buat sebuah akun ntfy", + "signup_form_password": "Kata sandi", + "login_link_signup": "Daftar", + "action_bar_sign_up": "Daftar", + "signup_form_username": "Nama pengguna", + "login_form_button_submit": "Masuk", + "action_bar_sign_in": "Masuk", + "nav_upgrade_banner_label": "Tingkatkan ke ntfy Pro", + "alert_not_supported_context_description": "Notifikasi hanya didukung melalui HTTPS. Ini adalah batasan API Notifikasi.", + "display_name_dialog_title": "Ubah nama tampilan", + "display_name_dialog_description": "Tetapkan nama alternatif untuk sebuah topik yang ditampilkan di daftar langganan. Ini membantu mengidentifikasi topik dengan nama yang rumit dengan lebih mudah.", + "subscribe_dialog_error_topic_already_reserved": "Topik sudah direservasi", + "account_basics_username_title": "Nama pengguna", + "account_basics_username_admin_tooltip": "Anda adalah Admin", + "account_basics_password_title": "Kata sandi", + "account_basics_password_description": "Ubah kata sandi akun Anda", + "account_basics_password_dialog_title": "Ubah kata sandi", + "account_basics_password_dialog_current_password_label": "Kata sandi saat ini", + "account_basics_password_dialog_confirm_password_label": "Konfirmasi kata sandi", + "account_basics_password_dialog_button_submit": "Ubah kata sandi", + "account_basics_password_dialog_current_password_incorrect": "Kata sandi salah", + "account_usage_title": "Penggunaan", + "account_usage_of_limit": "dari {{limit}}", + "account_usage_unlimited": "Tidak terbatas", + "account_usage_limits_reset_daily": "Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)", + "account_basics_tier_title": "Jenis akun", + "account_basics_tier_description": "Tingkat daya akun Anda", + "account_basics_tier_admin_suffix_no_tier": "(tidak ada peringkat)", + "account_basics_tier_basic": "Dasaran", + "account_basics_tier_change_button": "Ubah", + "account_basics_tier_paid_until": "Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis", + "account_basics_tier_canceled_subscription": "Langganan Anda dibatalkan dan akan diturunkan ke akun gratis pada {{date}}.", + "account_usage_messages_title": "Pesan terkirim", + "account_usage_emails_title": "Surel terkirim", + "account_usage_reservations_title": "Topik yang telah direservasi", + "account_usage_reservations_none": "Tidak ada topik yang telah direservasi untuk akun ini", + "account_usage_attachment_storage_title": "Penyimpanan lampiran", + "account_usage_attachment_storage_description": "{{filesize}} per berkas, dihapus setelah {{expiry}}", + "account_delete_title": "Hapus akun", + "account_delete_description": "Hapus akun Anda secara permanen", + "account_delete_dialog_label": "Kata sandi", + "account_delete_dialog_button_cancel": "Batal", + "account_delete_dialog_button_submit": "Hapus akun secara permanen", + "account_usage_cannot_create_portal_session": "Tidak dapat membuka portal tagihan", + "account_delete_dialog_billing_warning": "Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.", + "account_upgrade_dialog_title": "Ubah peringkat akun", + "account_upgrade_dialog_proration_info": "Prorasi: Saat melakukan upgrade antar paket berbayar, selisih harga akan langsung dibebankan ke. Saat menurunkan ke tingkat yang lebih rendah, saldo akan digunakan untuk membayar periode penagihan di masa mendatang.", + "account_upgrade_dialog_reservations_warning_other": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, silakan menghapus setidaknya {{count}} reservasi. Anda dapat menghapus reservasi di Pengaturan.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} topik yang telah direservasi", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} pesan harian", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} surel harian", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per berkas", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} jumlah penyimpanan", + "account_upgrade_dialog_tier_selected_label": "Dipilih", + "account_upgrade_dialog_tier_current_label": "Saat ini", + "account_upgrade_dialog_button_cancel": "Batal", + "account_upgrade_dialog_button_redirect_signup": "Daftar sekarang", + "account_upgrade_dialog_button_pay_now": "Bayar sekaramg dan berlangganan", + "account_upgrade_dialog_button_cancel_subscription": "Batalkan langganan", + "account_upgrade_dialog_button_update_subscription": "Perbarui langganan", + "account_tokens_title": "Token akses", + "account_tokens_description": "Gunakan token akses saat mengirim dan berlangganan melalui API ntfy, sehingga Anda tidak perlu mengirimkan kredensial akun Anda. Lihat dokumentasi untuk mempelajari lebih lanjut.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Label", + "account_tokens_table_last_access_header": "Akses terakhir", + "account_tokens_table_expires_header": "Kedaluwarsa", + "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa", + "account_tokens_table_current_session": "Sesi peramban saat ini", + "common_copy_to_clipboard": "Salin ke papan klip", + "account_tokens_table_copied_to_clipboard": "Token akses disalin", + "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", + "account_tokens_table_create_token_button": "Buat token akses", + "account_tokens_dialog_expires_unchanged": "Tinggalkan tanggal kedaluwarsa tidak terganti", + "account_tokens_dialog_expires_x_hours": "Token kedaluwarsa dalam {{hours}} jam", + "account_tokens_dialog_expires_x_days": "Token kedaluwarsa dalam {{days}} hari", + "account_tokens_dialog_expires_never": "Token tidak pernah kedaluwarsa", + "account_tokens_delete_dialog_title": "Hapus token akses", + "account_tokens_delete_dialog_description": "Sebelum menghapus sebuah token akses, pastikan bahwa tidak ada aplikasi atau skrip yang sedang menggunakannya secara aktif. Tindakan ini tidak dapat diurungkan.", + "account_tokens_delete_dialog_submit_button": "Hapus token secara permanan", + "prefs_reservations_title": "Topik yang direservasi", + "reservation_delete_dialog_action_keep_title": "Jaga tembolok pesan dan lampiran", + "reservation_delete_dialog_action_keep_description": "Tembolok pesan dan lampiran yang berada di server akan terlihat secara publik untuk orang-orang dengan pengetahuan nama topik.", + "reservation_delete_dialog_action_delete_title": "Hapus tembolok pesan dan lampiran", + "reservation_delete_dialog_action_delete_description": "Tembolok pesan dan lampiran akan dihapus secara permanen. Tindakan ini tidak dapat diurungkan.", + "reservation_delete_dialog_submit_button": "Hapus reservasi", + "prefs_reservations_table_everyone_read_only": "Saya dapat mengirim dan berlangganan, semuanya dapat berlangganan", + "prefs_reservations_dialog_title_edit": "Sunting reservasi topik", + "subscribe_dialog_subscribe_button_generate_topic_name": "Buat nama", + "account_basics_title": "Akun", + "account_basics_tier_admin_suffix_with_tier": "(dengan peringkat {{tier}})", + "account_basics_tier_free": "Gratis", + "account_tokens_dialog_expires_label": "Token akses kedaluwarsa dalam", + "account_basics_username_description": "Hei, itu Anda ❤", + "account_basics_password_dialog_new_password_label": "Kata sandi baru", + "account_basics_tier_admin": "Admin", + "account_basics_tier_upgrade_button": "Tingkatkan ke Pro", + "account_basics_tier_payment_overdue": "Pembayaran Anda telah jatuh tempo. Mohon perbarui metode pembayaran Anda, atau akun Anda akan segera diturunkan.", + "account_basics_tier_manage_billing_button": "Kelola pembayaran", + "account_tokens_dialog_title_delete": "Hapus token akses", + "account_usage_basis_ip_description": "Statistik dan batasan pengguna untuk akun ini berdasarkan alamat IP Anda, sehingga mereka mungkin terbagi dengan pengguna lain. Batasan yang ditampilkan di atas adalah perkiraan berdasarkan batas tarif yang sudah ada.", + "account_delete_dialog_description": "Ini akan menghapus akun Anda secara permanen, termasuk semua data yang telah disimpan di server ini. Setelah penghapusan, nama pengguna Anda akan tidak tersedia selama 7 hari. Jika Anda ingin melanjutkan, silakan mengonfirmasi dengan kata sandi Anda di kotak bawah.", + "account_upgrade_dialog_cancel_warning": "Ini akan membatalkan langganan Anda, dan menurunkan akun Anda pada tanggal {{date}}. Pada tanggal itu, reservasi topik maupun tembolok pesan di server akan dihapus.", + "prefs_reservations_table_everyone_write_only": "Saya dapat mengirim dan berlangganan, semuanya dapat mengirim", + "account_tokens_table_last_origin_tooltip": "Dari alamat IP {{ip}}, klik untuk melihat", + "account_tokens_dialog_label": "Label, mis. notifikasi Radarr", + "account_tokens_dialog_button_create": "Buat token", + "prefs_reservations_description": "Anda dapat mereservasi nama topik untuk penggunaan pribadi di sini. Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.", + "account_upgrade_dialog_reservations_warning_one": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, silakan menghapus setidaknya satu reservasi. Anda dapat menghapus reservasi di Pengaturan.", + "account_tokens_dialog_button_cancel": "Batal", + "account_tokens_dialog_title_create": "Buat token akses", + "account_tokens_dialog_title_edit": "Sunting token akses", + "account_tokens_dialog_button_update": "Perbarui token", + "prefs_reservations_add_button": "Tambahkan reservasi topik", + "prefs_reservations_table": "Tabel topik yang telah direservasi", + "prefs_reservations_table_topic_header": "Topik", + "prefs_users_table_cannot_delete_or_edit": "Tidak dapat menghapus atau menyunting pengguna yang telah masuk", + "prefs_reservations_table_everyone_deny_all": "Hanya saya yang dapat mengirim dan berlangganan", + "prefs_reservations_table_everyone_read_write": "Semuanya dapat mengirim dan berlangganan", + "prefs_users_description_no_sync": "Pengguna dan kata sandi tidak disinkronkan ke akun Anda.", + "prefs_reservations_limit_reached": "Anda telah mencapai batasan reservasi topik.", + "prefs_reservations_edit_button": "Sunting akses topik", + "prefs_reservations_table_click_to_subscribe": "Klik untuk berlangganan", + "prefs_reservations_delete_button": "Atur ulang akses topik", + "prefs_reservations_table_access_header": "Akses", + "prefs_reservations_dialog_title_add": "Reservasi topik", + "prefs_reservations_dialog_title_delete": "Hapus reservasi topik", + "prefs_reservations_table_not_subscribed": "Tidak berlangganan", + "prefs_reservations_dialog_description": "Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.", + "prefs_reservations_dialog_topic_label": "Topik", + "prefs_reservations_dialog_access_label": "Akses", + "reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya.", + "account_upgrade_dialog_interval_yearly": "Setiap tahun", + "account_upgrade_dialog_tier_price_billed_yearly": "Ditagih {{price}} setiap tahun. Hemat {{save}}.", + "account_upgrade_dialog_interval_yearly_discount_save": "hemat {{discount}}%", + "account_upgrade_dialog_interval_monthly": "Setiap bulan", + "account_basics_tier_interval_monthly": "setiap bulan", + "account_basics_tier_interval_yearly": "setiap tahun", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "hemat sampai {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "Tidak ada topik yang direservasi", + "account_upgrade_dialog_tier_price_per_month": "bulan", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per bulan. Ditagih setiap bulan.", + "account_upgrade_dialog_billing_contact_email": "Untuk pertanyaan penagihan, silakan hubungi kami secara langsung.", + "account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke situs web kami.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian", + "publish_dialog_call_label": "Panggilan telepon", + "publish_dialog_call_placeholder": "Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'", + "account_basics_phone_numbers_title": "Nomor telepon", + "account_basics_phone_numbers_dialog_description": "Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.", + "account_basics_phone_numbers_no_phone_numbers_yet": "Belum ada nomor telepon", + "account_basics_phone_numbers_dialog_title": "Tambahkan nomor telepon", + "account_basics_phone_numbers_dialog_number_label": "Nomor telepon", + "account_basics_phone_numbers_dialog_number_placeholder": "mis. +62222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Kirim SMS", + "account_basics_phone_numbers_dialog_channel_call": "Panggil", + "account_usage_calls_title": "Panggilan telepon dilakukan", + "account_usage_calls_none": "Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} panggilan telepon harian", + "publish_dialog_call_reset": "Hapus panggilan telepon", + "account_basics_phone_numbers_description": "Untuk notifikasi panggilan telepon", + "account_basics_phone_numbers_copied_to_clipboard": "Nomor telepon disalin ke papan klip", + "publish_dialog_chip_call_label": "Panggilan telepon", + "account_basics_phone_numbers_dialog_verify_button_call": "Panggil saya", + "account_basics_phone_numbers_dialog_code_placeholder": "mis. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Konfirmasi kode", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian", + "account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon", + "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi", + "publish_dialog_call_item": "Panggil nomor telepon {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi" } diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json new file mode 100644 index 00000000..4833e8fa --- /dev/null +++ b/web/public/static/langs/it.json @@ -0,0 +1,308 @@ +{ + "action_bar_logo_alt": "logo ntfy", + "action_bar_settings": "Impostazioni", + "action_bar_clear_notifications": "Cancella tutte le notifiche", + "action_bar_unsubscribe": "Annulla l'iscrizione", + "action_bar_toggle_action_menu": "Apri/chiudi il menu delle azioni", + "message_bar_type_message": "Digita un messaggio qui", + "message_bar_error_publishing": "Errore durante la pubblicazione della notifica", + "message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione", + "message_bar_publish": "Pubblica messaggio", + "nav_topics_title": "Topic a cui si è iscritti", + "nav_button_all_notifications": "Tutte le notifiche", + "nav_button_settings": "Impostazioni", + "nav_button_publish_message": "Pubblica notifica", + "nav_button_subscribe": "Iscriviti al topic", + "nav_button_muted": "Notifiche disattivate", + "nav_button_connecting": "connessione", + "alert_notification_permission_required_title": "Le notifiche sono disabilitate", + "alert_notification_permission_required_button": "Concedi ora", + "notifications_list": "Elenco notifiche", + "notifications_list_item": "Notifiche", + "notifications_mark_read": "Segna come letto", + "notifications_delete": "Elimina", + "notifications_copied_to_clipboard": "Copiato negli appunti", + "notifications_tags": "Tags", + "notifications_priority_x": "Priorità {{priority}}", + "notifications_new_indicator": "Nuova notifica", + "notifications_attachment_image": "Immagine allegata", + "notifications_attachment_copy_url_title": "Copia l'URL dell'allegato negli appunti", + "notifications_attachment_copy_url_button": "Copia URL", + "notifications_attachment_open_title": "Vai a {{url}}", + "notifications_attachment_open_button": "Apri allegato", + "notifications_attachment_link_expires": "Il collegamento scade il {{date}}", + "notifications_attachment_link_expired": "link per il download scaduto", + "notifications_attachment_file_image": "file immagine", + "notifications_attachment_file_video": "file video", + "action_bar_toggle_mute": "Abilita/disabilita le notifiche", + "notifications_attachment_file_document": "altro documento", + "notifications_click_copy_url_button": "Copia link", + "notifications_click_open_button": "Apri link", + "notifications_actions_open_url_title": "Vai a {{url}}", + "notifications_actions_not_supported": "Azione non supportata nell'app Web", + "notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo topic.", + "notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL del topic.", + "notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.", + "notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.", + "notifications_example": "Esempio", + "notifications_more_details": "Per ulteriori informazioni, consulta il sito web o documentazione.", + "notifications_loading": "Caricamento notifiche in corso…", + "publish_dialog_title_topic": "Pubblica su {{topic}}", + "publish_dialog_title_no_topic": "Pubblica notifica", + "publish_dialog_progress_uploading": "Caricamento in corso…", + "publish_dialog_progress_uploading_detail": "Caricamento {{loaded}}/{{total}} ({{percent}}%)…", + "publish_dialog_message_published": "Notifica pubblicata", + "publish_dialog_attachment_limits_file_and_quota_reached": "supera {{fileSizeLimit}} limite di file e quota, {{remainingBytes}} rimanenti", + "publish_dialog_attachment_limits_file_reached": "supera di {{fileSizeLimit}} il limite dei file", + "publish_dialog_attachment_limits_quota_reached": "supera la quota, {{remainingBytes}} rimanenti", + "publish_dialog_emoji_picker_show": "Scegli emoji", + "publish_dialog_priority_min": "Min. priorità", + "publish_dialog_priority_low": "Bassa priorità", + "publish_dialog_priority_default": "Priorità predefinita", + "publish_dialog_priority_high": "Priorità alta", + "publish_dialog_priority_max": "Max. priorità", + "publish_dialog_base_url_label": "URL del servizio", + "publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com", + "publish_dialog_topic_label": "Nome topic", + "publish_dialog_topic_placeholder": "Nome topic, ad es. avvisi_di_phil", + "publish_dialog_topic_reset": "Reset topic", + "publish_dialog_title_label": "Titolo", + "publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco", + "publish_dialog_message_label": "Messaggio", + "publish_dialog_message_placeholder": "Digita un messaggio qui", + "publish_dialog_tags_label": "Tags", + "publish_dialog_priority_label": "Priorità", + "publish_dialog_click_label": "Clicca URL", + "publish_dialog_click_reset": "Rimuovi l'URL del clic", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Indirizzo a cui inoltrare la notifica, ad es. phil@example.com", + "publish_dialog_email_reset": "Rimuovi inoltro email", + "publish_dialog_attach_label": "URL Allegato", + "publish_dialog_attach_reset": "Rimuovi l'URL dell'allegato", + "publish_dialog_filename_label": "Nome del file", + "publish_dialog_filename_placeholder": "Nome file allegato", + "publish_dialog_delay_placeholder": "Consegna ritardata, ad es. {{unixTimestamp}}, {{relativeTime}} o \"{{naturalLanguage}}\" (solo in inglese)", + "publish_dialog_delay_reset": "Rimuovere la consegna ritardata", + "publish_dialog_other_features": "Altre funzionalità:", + "publish_dialog_chip_click_label": "Fare clic su URL", + "publish_dialog_chip_email_label": "Inoltra a e-mail", + "publish_dialog_chip_attach_url_label": "Allega il file tramite URL", + "publish_dialog_chip_attach_file_label": "Allega file locale", + "publish_dialog_chip_delay_label": "Ritardo nella consegna", + "publish_dialog_button_cancel_sending": "Annulla l'invio", + "publish_dialog_button_cancel": "Annulla", + "publish_dialog_button_send": "Invia", + "publish_dialog_checkbox_publish_another": "Pubblica un altro", + "publish_dialog_attached_file_title": "File allegato:", + "publish_dialog_attached_file_remove": "Rimuovi il file allegato", + "publish_dialog_drop_file_here": "Trascina il file qui", + "emoji_picker_search_clear": "Cancella ricerca", + "subscribe_dialog_subscribe_title": "Iscriviti al topic", + "subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil", + "subscribe_dialog_subscribe_base_url_label": "URL del servizio", + "subscribe_dialog_subscribe_button_cancel": "Annulla", + "subscribe_dialog_login_title": "Accesso richiesto", + "subscribe_dialog_login_username_label": "Nome utente, ad es. phil", + "subscribe_dialog_login_button_login": "Login", + "subscribe_dialog_error_user_anonymous": "anonimo", + "prefs_notifications_sound_title": "Suono di notifica", + "prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano", + "prefs_notifications_sound_no_sound": "Nessun suono", + "prefs_notifications_min_priority_description_any": "Visualizzazione di tutte le notifiche, indipendentemente dalla priorità", + "prefs_notifications_min_priority_description_max": "Mostra notifiche se la priorità è 5 (max)", + "prefs_notifications_min_priority_any": "Qualsiasi priorità", + "prefs_notifications_min_priority_low_and_higher": "Priorità bassa e superiore", + "prefs_notifications_min_priority_high_and_higher": "Priorità alta e superiore", + "prefs_notifications_min_priority_max_only": "Solo priorità massima", + "prefs_notifications_delete_after_never": "Mai", + "prefs_notifications_delete_after_three_hours": "Dopo tre ore", + "prefs_notifications_delete_after_one_day": "Dopo un giorno", + "prefs_notifications_delete_after_never_description": "Le notifiche non vengono mai eliminate automaticamente", + "prefs_notifications_delete_after_one_day_description": "Le notifiche vengono eliminate automaticamente dopo un giorno", + "prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana", + "prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese", + "prefs_users_title": "Gestisci gli utenti", + "prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi topic protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.", + "prefs_users_table": "Tabella utenti", + "prefs_users_add_button": "Aggiungi utente", + "prefs_users_edit_button": "Modifica utente", + "prefs_users_delete_button": "Elimina utente", + "prefs_users_table_user_header": "Utente", + "prefs_users_table_base_url_header": "URL del servizio", + "prefs_users_dialog_title_add": "Aggiungi utente", + "prefs_users_dialog_title_edit": "Modifica utente", + "prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh", + "prefs_users_dialog_username_label": "Nome utente, ad es. phil", + "prefs_users_dialog_password_label": "Password", + "common_cancel": "Annulla", + "common_add": "Aggiungere", + "common_save": "Salva", + "prefs_appearance_title": "Aspetto", + "prefs_appearance_language_title": "Lingua", + "priority_min": "min", + "priority_low": "basso", + "priority_default": "predefinito", + "priority_high": "alto", + "priority_max": "max", + "error_boundary_title": "Oh no, ntfy è andato in crash", + "error_boundary_description": "Questo ovviamente non dovrebbe accadere. Mi dispiace molto per questo.
Se hai un minuto, per favore segnala su GitHub, o faccelo sapere tramite Discord o Matrix .", + "error_boundary_button_copy_stack_trace": "Copia traccia dello stack", + "error_boundary_stack_trace": "Traccia dello stack", + "error_boundary_gathering_info": "Raccogli più informazioni…", + "error_boundary_unsupported_indexeddb_title": "Navigazione privata non supportata", + "action_bar_show_menu": "Mostra menu", + "action_bar_send_test_notification": "Inviare una notifica di prova", + "alert_not_supported_description": "Le notifiche non sono supportate nel tuo browser.", + "nav_button_documentation": "Documentazione", + "notifications_actions_http_request_title": "Invia HTTP {{method}} a {{url}}", + "alert_notification_permission_required_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.", + "alert_not_supported_title": "Notifiche non supportate", + "notifications_attachment_file_app": "file app Android", + "notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.", + "notifications_attachment_file_audio": "file audio", + "notifications_none_for_any_description": "Per inviare notifiche a un topic, è sufficiente PUT o POST all'URL del topic. Ecco un esempio utilizzando uno dei tuoi topic.", + "notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti", + "prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano", + "publish_dialog_delay_label": "Ritardo", + "publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1", + "publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica", + "publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk", + "publish_dialog_chip_topic_label": "Cambia topic", + "publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla documentazione.", + "publish_dialog_attached_file_filename_placeholder": "Nome file allegato", + "emoji_picker_search_placeholder": "Cerca emoji", + "subscribe_dialog_subscribe_description": "Gli argomenti potrebbero non essere protetti da password, quindi scegli un nome che non sia facile da indovinare. Una volta iscritto, puoi inviare le notifiche tramite PUT/POST.", + "subscribe_dialog_subscribe_use_another_label": "Usa un altro server", + "subscribe_dialog_login_password_label": "Password", + "subscribe_dialog_subscribe_button_subscribe": "Iscriviti", + "prefs_notifications_sound_play": "Riproduci il suono selezionato", + "prefs_notifications_min_priority_title": "Priorità minima", + "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.", + "common_back": "Indietro", + "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", + "prefs_notifications_title": "Notifiche", + "prefs_notifications_delete_after_title": "Elimina le notifiche", + "prefs_notifications_min_priority_default_and_higher": "Priorità predefinita e superiore", + "prefs_notifications_min_priority_description_x_or_higher": "Mostra le notifiche se la priorità è {{number}} ({{name}}) o superiore", + "prefs_notifications_delete_after_one_week": "Dopo una settimana", + "prefs_notifications_delete_after_one_month": "Dopo un mese", + "prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore", + "error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.

Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo in questo numero di GitHub o parlarci su Discord o Matrix.", + "nav_upgrade_banner_label": "Passa alla versione Pro di ntfy", + "alert_not_supported_context_description": "Le Notificche sono supportate solo tramite HTTPS. Questa è una limitazione delle Notifications API.", + "account_basics_password_dialog_new_password_label": "Nuova password", + "action_bar_profile_logout": "Esci", + "account_basics_tier_interval_monthly": "mensile", + "account_basics_tier_interval_yearly": "annuale", + "account_basics_tier_upgrade_button": "Passa alla versione Pro", + "account_basics_tier_change_button": "Cambia", + "account_basics_tier_paid_until": "Abbonamento pagato fino a {{data}}, e si rinnoverà automaticamente", + "account_basics_tier_payment_overdue": "Il pagamento è scaduto. La preghiamo di aggiornare il suo metodo di pagamento, altrimenti il suo account verrà presto declassato.", + "account_basics_tier_canceled_subscription": "L'abbonamento è stato annullato e sarà declassato ad account gratuito a partire dalla {{data}}.", + "account_basics_tier_manage_billing_button": "Gestire la fatturazione", + "account_usage_messages_title": "Messaggi pubblicati", + "account_usage_reservations_title": "Argomenti riservati", + "account_usage_reservations_none": "Non ci sono argomenti riservati per questo account", + "signup_form_toggle_password_visibility": "Imposta la visibilità della password", + "signup_already_have_account": "Hai già un account? Accedi!", + "signup_disabled": "Registrazione disabilitata", + "signup_title": "Crea un account ntfy", + "signup_form_username": "Nome utente", + "signup_form_password": "Password", + "signup_form_confirm_password": "Conferma password", + "signup_form_button_submit": "Registrazione", + "signup_error_username_taken": "Il nome utente {{username}} è già utilizzato", + "signup_error_creation_limit_reached": "Il limite per la creazione di account è stato raggiunto", + "login_title": "Accedi al tuo account ntfy", + "login_form_button_submit": "Accedi", + "login_link_signup": "Registrati", + "login_disabled": "L'accesso è disabilitato", + "action_bar_account": "Account", + "action_bar_change_display_name": "Cambia il nome da visualizzare", + "action_bar_reservation_limit_reached": "Limite raggiunto", + "action_bar_profile_title": "Profilo", + "action_bar_profile_settings": "Impostazioni", + "action_bar_reservation_add": "Riserva un argomento", + "action_bar_reservation_edit": "Modifica l'argomento riservato", + "action_bar_reservation_delete": "Rimuovi l'argomento riservato", + "action_bar_sign_in": "Accedi", + "action_bar_sign_up": "Registrati", + "nav_button_account": "Account", + "nav_upgrade_banner_description": "Riserva argomenti, più messaggi ed e-mail e allegati più grandi", + "display_name_dialog_description": "Imposta un nome alternativo per un argomento che viene visualizzato nell'elenco delle sottoscrizioni. Questo aiuta a identificare più facilmente gli argomenti con nomi complicati.", + "display_name_dialog_title": "Cambia il nome visualizzato", + "display_name_dialog_placeholder": "Nome visualizzato", + "reserve_dialog_checkbox_label": "Riserva un argomento e configura l'accesso", + "subscribe_dialog_subscribe_button_generate_topic_name": "Genera un nome", + "subscribe_dialog_error_topic_already_reserved": "Argomento già in uso", + "account_basics_title": "Account", + "account_basics_username_title": "Nome utente", + "account_basics_username_admin_tooltip": "Sei Amministratore", + "account_basics_password_title": "Password", + "account_basics_password_description": "Cambia la password del tuo account", + "account_basics_password_dialog_title": "Cambia la password", + "account_basics_password_dialog_current_password_label": "Password attuale", + "account_basics_password_dialog_confirm_password_label": "Conferma la password", + "account_basics_password_dialog_button_submit": "Cambia la password", + "account_basics_password_dialog_current_password_incorrect": "Password errata", + "account_usage_title": "Utilizzo", + "account_usage_of_limit": "di {{limit}}", + "account_usage_unlimited": "Illimitato", + "account_usage_limits_reset_daily": "I limiti di utilizzo vengono azzerati ogni giorno a mezzanotte (orario UTC)", + "account_basics_tier_title": "Tipo di account", + "account_basics_tier_description": "Permessi del tuo account", + "account_basics_tier_admin": "Amministratore", + "account_basics_tier_admin_suffix_with_tier": "(con livello {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(nessun livello)", + "account_basics_tier_basic": "Base", + "account_basics_tier_free": "Gratuito", + "account_usage_emails_title": "Email inviate", + "account_usage_cannot_create_portal_session": "Impossibile aprire il portale di pagamento", + "account_delete_title": "Elimina account", + "account_basics_username_description": "Hey, sei tu ❤", + "publish_dialog_call_item": "Chiama numero {{number}}", + "common_copy_to_clipboard": "Copia negli appunti", + "publish_dialog_call_label": "Chiamata telefonica", + "publish_dialog_call_reset": "Rimuovi chiamata telefonica", + "publish_dialog_chip_call_label": "Chiamata telefonica", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Nessun numero verificato", + "account_basics_phone_numbers_title": "Numeri di telefono", + "account_basics_phone_numbers_dialog_description": "Per usare la funzionalità di notifica tramite chiamata telefonica, devi aggiungere e verificare almeno un numero di telefono. La verifica può essere fatta tramite SMS o chiamata telefonica.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topic riservato", + "account_upgrade_dialog_billing_contact_email": "Per domande di fatturazione, contattaci direttamente.", + "account_upgrade_dialog_tier_current_label": "Attuale", + "account_basics_phone_numbers_dialog_number_label": "Numero di telefono", + "account_basics_phone_numbers_dialog_check_verification_button": "Conferma codice", + "account_basics_phone_numbers_dialog_verify_button_sms": "Invia SMS", + "account_basics_phone_numbers_no_phone_numbers_yet": "Ancora nessun numero di telefono", + "account_basics_phone_numbers_dialog_title": "Aggiungi un numero di telefono", + "account_upgrade_dialog_button_cancel": "Cancella", + "account_upgrade_dialog_billing_contact_website": "Per domande di fatturazione, visita per favore in nostro sito.", + "account_upgrade_dialog_button_cancel_subscription": "Cancella iscrizione", + "account_basics_phone_numbers_description": "Per notifiche via chiamata", + "account_basics_phone_numbers_copied_to_clipboard": "Numero di telefono copiato negli appunti", + "account_basics_phone_numbers_dialog_number_placeholder": "p. e. +391234567890", + "account_basics_phone_numbers_dialog_code_placeholder": "p. e. 123456", + "account_tokens_title": "Token d'accesso", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} all'anno. Addebitato annualmente.", + "account_basics_phone_numbers_dialog_channel_call": "Chiama", + "account_upgrade_dialog_button_redirect_signup": "Iscriviti ora", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} addebitato annualmente. Risparmia {{save}}.", + "account_upgrade_dialog_tier_price_per_month": "mese", + "account_upgrade_dialog_button_pay_now": "Paga ora e isciviti", + "account_basics_phone_numbers_dialog_code_label": "Codice di verifica", + "account_basics_phone_numbers_dialog_verify_button_call": "Chiamami", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_selected_label": "Selezionato", + "account_upgrade_dialog_button_update_subscription": "Aggiorna iscrizione", + "account_usage_attachment_storage_title": "Archivio allegati", + "account_delete_dialog_description": "Il tuo account sarà permanentemente cancellato assieme a tutti i tuoi dati presenti sul server. Dopo la cancellazione, la tua username non sarà disponibile per 7 giorni. Se desideri davvero procedere, inserisci la tua password nella seguente casella.", + "account_delete_dialog_button_cancel": "Annulla", + "account_usage_calls_title": "Chiamate effettuate", + "account_delete_description": "Elimina permanentemente il tuo account", + "account_delete_dialog_button_submit": "Elimina il tuo account permanentemente", + "account_usage_basis_ip_description": "Le statistiche di utilizzo e i limiti per questo account sono basati sul tuo indirizzo IP, quindi potrebbero essere in condivisione con altri utenti. I limiti mostrati sopra sono approssimazioni basate sui limiti esistenti.", + "account_usage_calls_none": "Questo account non può effettuare chiamate", + "account_delete_dialog_billing_warning": "Eliminando il tuo account perderai immediatamente il tuo abbonamento. Non potrai più accedere alla dashboard di fatturazione.", + "account_delete_dialog_label": "Password" +} diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index e9d51cbc..84afc30b 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -20,7 +20,7 @@ "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_password_label": "パスワード", - "subscribe_dialog_login_button_back": "戻る", + "common_back": "戻る", "subscribe_dialog_login_button_login": "ログイン", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_max_only": "優先度最高のみ", @@ -28,13 +28,13 @@ "message_bar_type_message": "メッセージを入力してください", "nav_topics_title": "購読しているトピック", "nav_button_subscribe": "トピックを購読", - "alert_grant_description": "ブラウザのデスクトップ通知を許可してください。", - "alert_grant_button": "許可する", + "alert_notification_permission_required_description": "ブラウザのデスクトップ通知を許可してください。", + "alert_notification_permission_required_button": "許可する", "notifications_attachment_link_expires": "リンクは {{date}} に失効します", "notifications_click_copy_url_button": "リンクをコピー", "notifications_none_for_topic_description": "トピックに通知を送信するには、トピックのURLにPUTかPOSTしてください。", "nav_button_publish_message": "通知を送信", - "alert_grant_title": "通知は無効化されています", + "alert_notification_permission_required_title": "通知は無効化されています", "alert_not_supported_title": "通知機能はサポートされていません", "notifications_tags": "タグ", "notifications_attachment_copy_url_button": "URLをコピー", @@ -49,7 +49,7 @@ "publish_dialog_message_label": "メッセージ", "publish_dialog_email_label": "メール", "notifications_none_for_any_title": "まだ通知を受信していません。", - "publish_dialog_priority_max": "優先度最高", + "publish_dialog_priority_max": "優先度 最高", "publish_dialog_button_cancel_sending": "送信をキャンセル", "publish_dialog_attach_label": "添付URL", "notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。", @@ -60,14 +60,14 @@ "publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com", "notifications_more_details": "詳しい情報は、ウェブサイト または ドキュメント を参照してください。", "publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました", - "publish_dialog_priority_min": "優先度最低", - "publish_dialog_priority_low": "優先度低", - "publish_dialog_priority_default": "優先度通常", + "publish_dialog_priority_min": "優先度 最低", + "publish_dialog_priority_low": "優先度 低", + "publish_dialog_priority_default": "優先度 通常", "publish_dialog_base_url_label": "サービスURL", "publish_dialog_other_features": "他の機能:", "notifications_loading": "通知を読み込み中…", "publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}", - "publish_dialog_priority_high": "優先度高", + "publish_dialog_priority_high": "優先度 高", "publish_dialog_topic_placeholder": "トピック名の例 phil_alerts", "publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告", "publish_dialog_message_placeholder": "メッセージ本文を入力してください", @@ -99,7 +99,7 @@ "prefs_notifications_delete_after_three_hours": "3時間後", "prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。", "prefs_users_add_button": "ユーザー追加", - "prefs_users_dialog_button_add": "追加", + "common_add": "追加", "subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用", "subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません", "prefs_notifications_delete_after_one_week": "1週間後", @@ -118,8 +118,8 @@ "prefs_notifications_min_priority_title": "表示する優先度", "prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上", "prefs_notifications_delete_after_title": "通知を削除", - "prefs_users_dialog_button_cancel": "キャンセル", - "prefs_users_dialog_button_save": "保存", + "common_cancel": "キャンセル", + "common_save": "保存", "prefs_users_table_user_header": "ユーザー名", "prefs_users_dialog_title_add": "ユーザー追加", "prefs_users_dialog_title_edit": "ユーザー編集", @@ -129,7 +129,7 @@ "prefs_users_table_base_url_header": "サービスURL", "prefs_users_dialog_username_label": "ユーザー名, 例) phil", "prefs_users_dialog_password_label": "パスワード", - "error_boundary_title": "ああ、ntfyがクラッシュしました", + "error_boundary_title": "おっと、ntfyがクラッシュしました", "error_boundary_button_copy_stack_trace": "スタックトレースをコピー", "error_boundary_stack_trace": "スタックトレース", "error_boundary_gathering_info": "更に情報を集める…", @@ -150,5 +150,235 @@ "priority_default": "通常", "prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます", "priority_low": "低", - "priority_min": "最低" + "priority_min": "最低", + "notifications_actions_not_supported": "このアクションはWebアプリではサポートされていません", + "notifications_actions_http_request_title": "{{url}}にHTTP {{method}}を送信", + "prefs_users_edit_button": "ユーザーを編集", + "publish_dialog_attached_file_remove": "添付ファイルを削除", + "error_boundary_unsupported_indexeddb_description": "nfty webアプリは動作にIndexedDBを使用しますが、あなたのブラウザはプライベートブラウジングモード時にIndexedDBをサポートしていません。

これは残念なことですが、ntfy webアプリは全ての情報をブラウザストレージに保存して動作するため、プライベートブラウジングモードで利用するのはあまり意味がないかも知れません。詳細については GitHub issueを参照するか、DiscordMatrixの議論に参加してください。", + "action_bar_show_menu": "メニューを表示", + "action_bar_logo_alt": "ntfyロゴ", + "action_bar_toggle_mute": "通知をミュート/解除", + "action_bar_toggle_action_menu": "動作メニューを開く/閉じる", + "message_bar_show_dialog": "送信ダイアログを表示", + "message_bar_publish": "メッセージを送信", + "nav_button_muted": "ミュートされた通知", + "nav_button_connecting": "接続中", + "notifications_list": "通知一覧", + "notifications_new_indicator": "新しい通知", + "notifications_list_item": "通知", + "notifications_mark_read": "既読にする", + "notifications_delete": "削除", + "notifications_priority_x": "優先度 {{priority}}", + "notifications_attachment_image": "添付画像", + "notifications_attachment_file_image": "画像ファイル", + "notifications_attachment_file_video": "動画ファイル", + "notifications_attachment_file_audio": "音声ファイル", + "notifications_attachment_file_app": "Androidアプリファイル", + "notifications_attachment_file_document": "その他文書", + "publish_dialog_emoji_picker_show": "絵文字", + "publish_dialog_topic_reset": "トピックをリセット", + "publish_dialog_click_reset": "クリックURLを削除", + "publish_dialog_email_reset": "メール転送を削除", + "publish_dialog_attach_reset": "添付URLを削除", + "publish_dialog_delay_reset": "配信遅延を削除", + "emoji_picker_search_clear": "検索をクリア", + "subscribe_dialog_subscribe_base_url_label": "サーバーURL", + "prefs_notifications_sound_play": "選択されたサウンドを再生", + "prefs_users_table": "ユーザー一覧", + "prefs_users_delete_button": "ユーザーを削除", + "error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません", + "signup_form_username": "ユーザー名", + "signup_form_password": "パスワード", + "signup_form_confirm_password": "パスワードを確認", + "signup_already_have_account": "アカウントをお持ちならサインイン", + "signup_disabled": "サインアップは無効化されています", + "signup_error_creation_limit_reached": "アカウント作成制限に達しました", + "login_title": "あなたのntfyアカウントにサインイン", + "login_link_signup": "サインアップ", + "login_disabled": "ログインは無効化されています", + "action_bar_account": "アカウント", + "action_bar_change_display_name": "表示名を変更する", + "action_bar_reservation_add": "トピックを予約する", + "action_bar_reservation_edit": "予約を編集する", + "action_bar_reservation_limit_reached": "制限に達しました", + "action_bar_profile_title": "プロファイル", + "action_bar_profile_settings": "設定", + "action_bar_profile_logout": "ログアウト", + "action_bar_sign_in": "サインイン", + "action_bar_sign_up": "サインアップ", + "nav_button_account": "アカウント", + "nav_upgrade_banner_label": "ntfy Proにアップグレード", + "display_name_dialog_title": "表示名を変更", + "display_name_dialog_placeholder": "表示名", + "signup_form_button_submit": "サインアップ", + "signup_form_toggle_password_visibility": "パスワードを表示/非表示", + "signup_title": "ntfyアカウントを作成する", + "login_form_button_submit": "サインイン", + "alert_not_supported_context_description": "通知はHTTPSのみサポートされています。これはNotifications APIの制限によるものです。", + "nav_upgrade_banner_description": "トピックを予約、より多くのメッセージとメール、より大きい添付ファイル", + "signup_error_username_taken": "ユーザー名 {{username}} は既に使用されています", + "action_bar_reservation_delete": "予約を削除する", + "display_name_dialog_description": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。", + "reserve_dialog_checkbox_label": "トピックを保存してアクセスを編集", + "subscribe_dialog_subscribe_button_generate_topic_name": "名前を生成", + "subscribe_dialog_error_topic_already_reserved": "このトピックは予約済みです", + "account_basics_title": "アカウント", + "account_basics_tier_description": "アカウントのパワーレベル", + "account_basics_tier_admin": "管理者", + "account_basics_tier_admin_suffix_with_tier": "(ティア {{tier}})", + "account_basics_tier_free": "無料", + "account_usage_attachment_storage_description": "1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除", + "account_usage_basis_ip_description": "アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。", + "account_usage_cannot_create_portal_session": "支払いポータルを開けませんでした", + "account_delete_title": "アカウントを削除", + "account_delete_description": "アカウントを永久的に削除", + "account_delete_dialog_description": "サーバーに保存されている全てのデータを含むあなたのアカウント情報を削除します。削除後、あなたのユーザー名は7日間利用できません。もし本当に先に進めたい場合、下の入力欄にパスワードを入力して確認して下さい。", + "account_delete_dialog_label": "パスワード", + "account_delete_dialog_button_cancel": "キャンセル", + "account_delete_dialog_button_submit": "永久的にアカウントを削除", + "account_delete_dialog_billing_warning": "アカウントを削除するとサブスクリプション支払いも即時キャンセルされます。支払いダッシュボードにもアクセスできなくなります。", + "account_upgrade_dialog_title": "アカウントティアを変更", + "account_upgrade_dialog_cancel_warning": "これによりサブスクリプションをキャンセルし{{date}}にアカウントをダウングレードします。同日、トピック予約およびサーバーにキャッシュされたメッセージは削除されます。", + "account_upgrade_dialog_proration_info": "追記。有料プランをアップグレードする場合、価格差は即座に請求されます。ダウングレードする場合、差額は次の請求期間の支払いに利用されます。", + "account_upgrade_dialog_tier_features_reservations_other": "予約のトピック{{reservations}}件", + "account_upgrade_dialog_tier_features_emails_other": "日次メール{{emails}}件", + "account_upgrade_dialog_tier_features_messages_other": "日次メッセージ{{messages}}件", + "account_upgrade_dialog_tier_selected_label": "選択", + "account_upgrade_dialog_tier_current_label": "現在", + "account_upgrade_dialog_button_cancel": "キャンセル", + "account_upgrade_dialog_button_redirect_signup": "サインアップ", + "account_upgrade_dialog_button_pay_now": "支払いしてサブスクライブする", + "account_upgrade_dialog_button_cancel_subscription": "サブスクリプションをキャンセル", + "account_upgrade_dialog_button_update_subscription": "サブスクリプションを更新", + "account_tokens_description": "ntfy APIで発行または購読する際にアクセストークンを使うことで、アカウント認証情報を送信する必要がなくなります。詳細はドキュメントを確認して下さい。", + "account_tokens_table_token_header": "トークン", + "account_tokens_table_label_header": "ラベル", + "account_tokens_table_last_access_header": "最終アクセス", + "account_tokens_table_expires_header": "期限", + "account_tokens_table_never_expires": "無期限", + "account_tokens_table_current_session": "現在のブラウザセッション", + "common_copy_to_clipboard": "クリップボードにコピー", + "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", + "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", + "account_tokens_table_create_token_button": "アクセストークンを生成", + "account_tokens_table_last_origin_tooltip": "IPアドレス {{ip}} から、クリックして参照", + "account_tokens_dialog_title_create": "アクセストークンを生成", + "account_tokens_dialog_title_edit": "アクセストークンを編集", + "account_tokens_dialog_title_delete": "アクセストークンを削除", + "account_tokens_dialog_label": "ラベル、例:Radarr通知", + "account_tokens_dialog_button_create": "トークンを生成", + "account_tokens_dialog_button_update": "トークンを更新", + "account_tokens_dialog_button_cancel": "キャンセル", + "account_tokens_dialog_expires_label": "アクセストークン有効期限", + "account_tokens_dialog_expires_unchanged": "有効期限を変更しない", + "account_tokens_dialog_expires_x_hours": "トークンは {{hours}} 時間後に失効します", + "account_tokens_dialog_expires_x_days": "トークンは {{days}} 日後に失効します", + "account_tokens_dialog_expires_never": "トークン失効なし", + "account_tokens_delete_dialog_title": "アクセストークンを削除", + "account_tokens_delete_dialog_submit_button": "トークンを永久削除", + "prefs_users_description_no_sync": "ユーザー名とパスワードはアカウントと同期されません。", + "prefs_users_table_cannot_delete_or_edit": "ログインしているユーザーは削除または編集できません", + "prefs_reservations_title": "予約されたトピック", + "prefs_reservations_description": "ここでトピック名を個人利用の為に予約する事ができます。トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。", + "prefs_reservations_add_button": "予約トピックを追加する", + "prefs_reservations_edit_button": "トピックへのアクセスを編集する", + "prefs_reservations_delete_button": "トピックへのアクセスをリセットする", + "prefs_reservations_table": "予約トピックの一覧", + "prefs_reservations_table_topic_header": "トピック", + "prefs_reservations_table_everyone_deny_all": "自分のみ発行と購読が可能", + "prefs_reservations_table_everyone_read_only": "自分は発行と購読が可能、誰でも購読可能", + "prefs_reservations_table_everyone_write_only": "自分は発行と購読可能、誰でも発行可能", + "prefs_reservations_table_everyone_read_write": "誰でも発行と購読が可能", + "prefs_reservations_table_not_subscribed": "購読されていません", + "prefs_reservations_table_click_to_subscribe": "クリックして購読", + "prefs_reservations_dialog_title_edit": "予約トピックを編集", + "prefs_reservations_dialog_title_delete": "トピック予約を削除", + "prefs_reservations_dialog_topic_label": "トピック", + "prefs_reservations_dialog_access_label": "アクセス", + "reservation_delete_dialog_action_keep_title": "キャッシュされたメッセージと添付ファイルを保持する", + "reservation_delete_dialog_action_keep_description": "サーバーにキャッシュされたメッセージと添付ファイルは公開されてトピック名を知っている人が閲覧できるようになります。", + "reservation_delete_dialog_action_delete_title": "キャッシュされたメッセージと添付ファイルを削除する", + "reservation_delete_dialog_action_delete_description": "キャッシュされたメッセージと添付ファイルは永久的に削除されます。この操作は元に戻せません。", + "account_basics_username_admin_tooltip": "あなたは管理者です", + "account_basics_password_title": "パスワード", + "account_basics_password_dialog_current_password_label": "現在のパスワード", + "account_usage_limits_reset_daily": "使用量制限は世界協定時 (UTC) の深夜に毎日リセットされます", + "account_basics_tier_basic": "ベーシック", + "account_basics_tier_paid_until": "サブスクリプションは{{date}}まで有効で、自動更新されます", + "account_basics_username_title": "ユーザー名", + "account_basics_username_description": "あなたのお名前です ❤", + "account_basics_password_description": "アカウントパスワードを変更", + "account_basics_password_dialog_title": "パスワード変更", + "account_basics_password_dialog_confirm_password_label": "パスワードを確認", + "account_basics_password_dialog_current_password_incorrect": "パスワードが異なります", + "account_usage_of_limit": ": {{limit}}", + "account_usage_unlimited": "無制限", + "account_basics_tier_upgrade_button": "プロにアップグレード", + "account_basics_tier_manage_billing_button": "支払い方法を管理", + "account_basics_password_dialog_new_password_label": "新しいパスワード", + "account_basics_password_dialog_button_submit": "パスワードを変更", + "account_usage_title": "使用量", + "account_basics_tier_title": "アカウントタイプ", + "account_basics_tier_admin_suffix_no_tier": "(ティアなし)", + "account_basics_tier_change_button": "変更", + "account_basics_tier_payment_overdue": "支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。", + "account_basics_tier_canceled_subscription": "あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。", + "account_usage_messages_title": "発行されたメッセージ", + "account_usage_reservations_none": "このアカウントで予約されたトピックはありません", + "account_usage_attachment_storage_title": "添付ストレージ", + "account_usage_emails_title": "送信済みメール", + "account_upgrade_dialog_reservations_warning_one": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、少なくとも1つの予約を削除してください。予約の削除は、設定で行うことができます。", + "account_usage_reservations_title": "予約されたトピック", + "account_upgrade_dialog_reservations_warning_other": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、少なくとも{{count}}個の予約を削除してください。予約の削除は、設定で行うことができます。", + "account_tokens_delete_dialog_description": "アクセストークンを削除する前に、アプリやスクリプトが利用中でないか確認して下さい。この操作は元に戻せません。", + "account_upgrade_dialog_tier_features_attachment_file_size": "1ファイルあたり{{filesize}}", + "account_upgrade_dialog_tier_features_attachment_total_size": "総ストレージ{{totalsize}}", + "account_tokens_title": "アクセストークン", + "prefs_reservations_limit_reached": "予約トピック数の上限に達しました。", + "prefs_reservations_table_access_header": "アクセス", + "prefs_reservations_dialog_title_add": "トピックを予約", + "prefs_reservations_dialog_description": "トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。", + "reservation_delete_dialog_description": "予約を削除するとトピックの所有権を失い、他の人が予約できるようになります。既存のメッセージや添付ファイルは保持または削除することができます。", + "reservation_delete_dialog_submit_button": "予約を削除", + "account_basics_tier_interval_monthly": "毎月", + "account_upgrade_dialog_interval_monthly": "毎月", + "account_upgrade_dialog_interval_yearly": "毎年", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "最大{{discount}}%節約", + "account_upgrade_dialog_tier_features_no_reservations": "予約トピックなし", + "account_upgrade_dialog_billing_contact_email": "支払いについての問い合わせは、直接お問い合わせください。", + "account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}%節約", + "account_basics_tier_interval_yearly": "毎年", + "account_upgrade_dialog_tier_price_per_month": "月", + "account_upgrade_dialog_tier_price_billed_monthly": "年間{{price}}。月毎の支払い。", + "account_upgrade_dialog_tier_price_billed_yearly": "年間{{price}}の支払い。{{save}}節約。", + "account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、ウェブサイトを参照して下さい。", + "account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ", + "account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件", + "account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件", + "publish_dialog_call_label": "電話", + "publish_dialog_call_item": "電話番号 {{number}}", + "account_basics_phone_numbers_title": "電話番号", + "account_usage_calls_none": "このアカウントからは電話を発信できません", + "account_usage_calls_title": "電話を発信しました", + "account_upgrade_dialog_tier_features_calls_one": "電話 1日 {{calls}} 回", + "account_upgrade_dialog_tier_features_no_calls": "電話なし", + "publish_dialog_call_reset": "電話番号を削除", + "publish_dialog_chip_call_label": "電話番号", + "account_basics_phone_numbers_dialog_description": "電話通知機能を使うには、最低ひとつの電話番号を追加して認証する必要があります。認証はSMSまたは電話で実施できます。", + "account_basics_phone_numbers_description": "電話通知", + "account_basics_phone_numbers_dialog_title": "電話番号を追加", + "account_basics_phone_numbers_no_phone_numbers_yet": "電話番号はまだありません", + "account_basics_phone_numbers_copied_to_clipboard": "電話番号がクリップボードにコピーされました", + "account_basics_phone_numbers_dialog_number_label": "電話番号", + "account_basics_phone_numbers_dialog_number_placeholder": "例 +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "SMSを送信", + "account_basics_phone_numbers_dialog_verify_button_call": "自分に電話する", + "account_basics_phone_numbers_dialog_code_label": "確認コード", + "account_basics_phone_numbers_dialog_code_placeholder": "例 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "確認コード", + "account_upgrade_dialog_tier_features_calls_other": "電話 1日 {{calls}} 回", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "認証済み電話番号がありません", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "電話する" } diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json new file mode 100644 index 00000000..ed35db70 --- /dev/null +++ b/web/public/static/langs/ko.json @@ -0,0 +1,191 @@ +{ + "action_bar_show_menu": "메뉴 표시", + "action_bar_logo_alt": "ntfy 로고", + "action_bar_settings": "설정", + "action_bar_send_test_notification": "시험용 알림 발송", + "action_bar_clear_notifications": "모든 알림 초기화", + "action_bar_unsubscribe": "구독 해제", + "action_bar_toggle_mute": "알림 음소거/해제", + "action_bar_toggle_action_menu": "액션 메뉴 열기/닫기", + "message_bar_type_message": "여기에 메세지를 입력하세요", + "message_bar_error_publishing": "메세지 발송 오류", + "message_bar_show_dialog": "발송 창 표시", + "message_bar_publish": "메세지 발송", + "nav_topics_title": "구독한 주제", + "nav_button_all_notifications": "모든 알림", + "nav_button_publish_message": "알림 보내기", + "nav_button_subscribe": "주제 구독하기", + "nav_button_muted": "알림 음소거됨", + "nav_button_connecting": "연결중", + "alert_notification_permission_required_title": "알림이 비활성화되어 있습니다", + "alert_notification_permission_required_description": "데스크톱 알림을 받기 위해서는 브라우저에서 권한을 부여해야 합니다.", + "alert_notification_permission_required_button": "권한 부여하기", + "alert_not_supported_title": "알림이 지원되지 않습니다", + "notifications_list_item": "알림", + "notifications_mark_read": "읽음으로 표시", + "notifications_delete": "삭제", + "notifications_copied_to_clipboard": "클립보드에 복사됨", + "notifications_tags": "태그", + "notifications_priority_x": "우선순위 {{priority}}", + "notifications_new_indicator": "새 알림", + "notifications_attachment_image": "첨부 이미지", + "notifications_attachment_copy_url_title": "첨부 주소를 클립보드에 복사", + "notifications_attachment_copy_url_button": "URL 복사", + "notifications_attachment_open_title": "{{url}}로 가기", + "publish_dialog_attachment_limits_file_and_quota_reached": "첨부파일 크기 제한({{fileSizeLimit}}) 초과 및 할당량 초과({{remainingBytes}} 남음)", + "publish_dialog_attachment_limits_file_reached": "첨부파일 크기 제한({{fileSizeLimit}}) 초과", + "publish_dialog_attachment_limits_quota_reached": "할당량 초과({{remainingBytes}} 남음)", + "publish_dialog_emoji_picker_show": "이모지 선택", + "publish_dialog_priority_min": "우선순위 최소", + "publish_dialog_priority_low": "우선순위 낮음", + "publish_dialog_priority_default": "우선순위 기본", + "publish_dialog_priority_high": "우선순위 높음", + "publish_dialog_priority_max": "우선순위 최상", + "publish_dialog_base_url_label": "서비스 URL", + "publish_dialog_base_url_placeholder": "서비스 URL, 예를 들면 https://example.com", + "publish_dialog_topic_label": "주제 이름", + "publish_dialog_topic_placeholder": "주제 이름, 예를 들면 phil_alerts", + "publish_dialog_topic_reset": "주제 초기화", + "publish_dialog_title_label": "제목", + "publish_dialog_title_placeholder": "알림 제목, 예를 들면 디스크 공간 경고", + "publish_dialog_message_label": "메세지", + "publish_dialog_message_placeholder": "메세지를 여기에 입력하세요", + "publish_dialog_tags_label": "태그", + "publish_dialog_tags_placeholder": "반점으로 구분된 태그 목록, 예를 들면 warning, srv1-backup", + "publish_dialog_priority_label": "우선순위", + "publish_dialog_click_label": "클릭 URL", + "publish_dialog_click_placeholder": "알림이 클릭되었을때 이동할 URL", + "publish_dialog_click_reset": "클릭 URL 제거", + "publish_dialog_email_label": "이메일", + "publish_dialog_email_placeholder": "알림을 전달할 이메일 주소, 예를 들면 phil@example.com", + "publish_dialog_email_reset": "이메일 전달 삭제", + "publish_dialog_attach_label": "첨부 파일 URL", + "publish_dialog_attach_placeholder": "파일을 URL로 첨부하기, 예를 들면 https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "첨부 파일 URL 삭제", + "publish_dialog_filename_label": "파일 이름", + "publish_dialog_filename_placeholder": "첨부 파일 이름", + "publish_dialog_delay_label": "지연", + "publish_dialog_chip_email_label": "이메일로 전달", + "publish_dialog_chip_attach_url_label": "URL로 파일 첨부", + "publish_dialog_chip_attach_file_label": "로컬 파일 첨부", + "publish_dialog_chip_delay_label": "발송 지연", + "publish_dialog_chip_topic_label": "주제 변경", + "publish_dialog_details_examples_description": "예제와 모든 전송 기능의 자세한 설명은 문서를 참고해주세요.", + "publish_dialog_button_cancel": "취소", + "publish_dialog_button_send": "보내기", + "publish_dialog_button_cancel_sending": "보내기 취소", + "publish_dialog_checkbox_publish_another": "다른 메세지 보내기", + "publish_dialog_attached_file_title": "첨부된 파일:", + "publish_dialog_attached_file_filename_placeholder": "첨부 파일 이름", + "publish_dialog_attached_file_remove": "첨부 파일 삭제", + "publish_dialog_drop_file_here": "여기에 파일을 끌어다 놓으세요", + "emoji_picker_search_placeholder": "이모지 검색", + "emoji_picker_search_clear": "검색 초기화", + "subscribe_dialog_subscribe_title": "주제 구독하기", + "subscribe_dialog_subscribe_description": "주제는 비밀번호로 보호되지 않을 수 있으니 추측하기 어려운 이름을 사용하십시오. 구독한 뒤 PUT/POST 알림을 보낼 수 있습니다.", + "subscribe_dialog_subscribe_topic_placeholder": "주제 이름, 예를 들면 phil_alerts", + "subscribe_dialog_subscribe_use_another_label": "다른 서버 사용", + "subscribe_dialog_subscribe_base_url_label": "서비스 URL", + "subscribe_dialog_subscribe_button_cancel": "취소", + "subscribe_dialog_subscribe_button_subscribe": "구독하기", + "subscribe_dialog_login_title": "로그인 필요함", + "subscribe_dialog_error_user_anonymous": "익명", + "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", + "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", + "subscribe_dialog_login_password_label": "비밀번호", + "common_back": "뒤로가기", + "subscribe_dialog_login_button_login": "로그인", + "prefs_notifications_title": "알림", + "prefs_notifications_sound_title": "알림 효과음", + "prefs_notifications_sound_description_none": "알림 도착시 효과음을 재생하지 않습니다", + "prefs_notifications_sound_description_some": "알림 도착시 {{sound}} 효과음이 재생됩니다", + "prefs_notifications_sound_no_sound": "효과음 없음", + "prefs_notifications_sound_play": "선택한 효과음 재생", + "prefs_notifications_min_priority_title": "우선순위 최소", + "prefs_notifications_min_priority_description_x_or_higher": "우선순위가 {{number}} ({{name}}) 이상인 알림만 보기", + "prefs_notifications_min_priority_description_max": "우선순위가 5 (최상)인 알림만 보기", + "prefs_notifications_min_priority_any": "아무 우선순위", + "prefs_notifications_min_priority_default_and_higher": "우선순위 기본 이상", + "prefs_notifications_min_priority_low_and_higher": "우선순위 낮음 이상", + "prefs_notifications_delete_after_three_hours": "3시간 뒤", + "prefs_notifications_delete_after_one_day": "1일 뒤", + "prefs_notifications_delete_after_one_week": "1주 뒤", + "prefs_notifications_delete_after_one_month": "1달 뒤", + "prefs_notifications_delete_after_never_description": "알림이 자동으로 삭제되지 않습니다", + "prefs_notifications_delete_after_three_hours_description": "알림이 3시간 뒤 자동으로 삭제됩니다", + "prefs_notifications_delete_after_one_day_description": "알림이 1일 뒤 자동으로 삭제됩니다", + "prefs_notifications_delete_after_one_week_description": "알림이 1주 뒤 자동으로 삭제됩니다", + "prefs_notifications_delete_after_one_month_description": "알림이 1달 뒤 자동으로 삭제됩니다", + "prefs_users_title": "사용자 관리", + "prefs_users_description": "이곳에서 보호된 주제를 위한 사용자를 추가하거나 삭제할 수 있습니다. 사용자 이름과 비밀번호는 브라우저의 로컬 저장소에 보관됩니다.", + "prefs_users_add_button": "사용자 추가", + "prefs_users_edit_button": "사용자 편집", + "prefs_users_delete_button": "사용자 삭제", + "prefs_users_table_user_header": "사용자", + "prefs_users_table_base_url_header": "서비스 URL", + "prefs_users_dialog_title_add": "사용자 추가", + "prefs_users_dialog_title_edit": "사용자 편집", + "prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh", + "common_cancel": "취소", + "common_save": "저장", + "prefs_appearance_title": "표시 설정", + "common_add": "추가", + "prefs_appearance_language_title": "언어", + "priority_min": "최하", + "priority_low": "낮음", + "priority_default": "기본", + "priority_high": "높음", + "error_boundary_title": "이런, ntfy가 충돌했습니다", + "error_boundary_button_copy_stack_trace": "스택 트레이스 복사", + "error_boundary_stack_trace": "스택 트레이스", + "error_boundary_gathering_info": "더 많은 정보 모으기 …", + "error_boundary_unsupported_indexeddb_title": "시크릿 모드는 지원되지 않습니다", + "notifications_click_copy_url_button": "링크 복사", + "notifications_click_copy_url_title": "링크 URL을 클립보드에 복사", + "notifications_attachment_file_video": "동영상 파일", + "notifications_attachment_file_app": "안드로이드 앱 파일", + "notifications_attachment_file_document": "다른 문서", + "notifications_click_open_button": "링크 열기", + "notifications_actions_not_supported": "웹앱에서 지원되지 않는 동작입니다", + "publish_dialog_title_topic": "{{topic}}에 발송", + "alert_not_supported_description": "사용중인 브라우저에서 알림 기능을 지원하지 않습니다.", + "notifications_example": "예제", + "notifications_more_details": "더 많은 정보가 필요하시다면 웹사이트문서를 참고하세요.", + "notifications_list": "알림 목록", + "notifications_attachment_open_button": "첨부 파일 열기", + "notifications_no_subscriptions_title": "아직 아무런 구독을 추가하지 않으신 것 같습니다.", + "nav_button_settings": "설정", + "nav_button_documentation": "문서", + "notifications_attachment_link_expires": "링크가 {{date}}에 만료됨", + "notifications_attachment_link_expired": "다운로드 링크 만료됨", + "notifications_attachment_file_audio": "음성 파일", + "notifications_attachment_file_image": "사진 파일", + "notifications_actions_open_url_title": "{{url}]로 가기", + "notifications_actions_http_request_title": "HTTP {{method}}를 {{url}}에 보내기", + "notifications_none_for_topic_title": "아직 이 주제 관련 알림을 받지 않았습니다.", + "notifications_none_for_any_title": "아직 어떤 알림도 받지 않았습니다.", + "notifications_none_for_any_description": "알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요. 구독중이신 주제 중 하나로 예를 들자면 다음과 같습니다.", + "notifications_loading": "알림 불러오는중 …", + "publish_dialog_message_published": "알림 발송됨", + "notifications_none_for_topic_description": "알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요.", + "notifications_no_subscriptions_description": "\"{{linktext}}\" 링크를 눌러서 주제를 생성하거나 구독하세요. 그 다음, 메세지를 PUT이나 POST로 보내면 여기에서 알림을 받으실 수 있습니다.", + "publish_dialog_progress_uploading": "업로드중 …", + "publish_dialog_title_no_topic": "알림 발송", + "publish_dialog_progress_uploading_detail": "업로드중 {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_delay_placeholder": "알림 발송 지연, 예를 들면 {{unixTimestamp}}, {{relativeTime}} 또는 \"{{naturalLanguage}}\" (영어로 입력)", + "publish_dialog_delay_reset": "발송 지연 삭제", + "publish_dialog_chip_click_label": "클릭 URL", + "subscribe_dialog_login_description": "이 주제는 비밀번호로 보호되어 있습니다. 구독하시려면 사용자 이름과 비밀번호를 입력해주세요.", + "prefs_notifications_min_priority_max_only": "우선순위 최상만", + "publish_dialog_other_features": "다른 기능:", + "prefs_notifications_min_priority_description_any": "우선순위 무관 모든 알림 보기", + "prefs_notifications_min_priority_high_and_higher": "우선순위 높음 이상", + "error_boundary_unsupported_indexeddb_description": "ntfy 웹 앱은 동작하기 위해서 IndexedDB가 필요하지만 사용중이신 브라우저는 IndexedDB를 시크릿 모드에서 지원하지 않습니다.

안타깝지만 모든 정보는 브라우저에만 저장되므로 ntfy 웹앱을 시크릿 모드에서 사용할 이유는 존재하지 않습니다. 이 깃허브 이슈를 참고해 보시거나, 디스코드 서버Matrix에서 저희와 이야기를 나눌 수 있습니다.", + "prefs_notifications_delete_after_title": "알림 삭제", + "prefs_notifications_delete_after_never": "삭제하지 않음", + "prefs_users_table": "사용자 테이블", + "prefs_users_dialog_username_label": "사용자 이름, 예를 들면 phil", + "prefs_users_dialog_password_label": "비밀번호", + "priority_max": "최상", + "error_boundary_description": "이것은 당연히 발생되어서는 안됩니다. 굉장히 죄송합니다.
가능하시다면 이 문제를 깃허브에 제보해 주시거나, 디스코드 서버Matrix를 통해 알려주세요." +} diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json index 1dab51b3..13cd419e 100644 --- a/web/public/static/langs/nb_NO.json +++ b/web/public/static/langs/nb_NO.json @@ -9,7 +9,7 @@ "nav_button_settings": "Innstillinger", "nav_button_documentation": "Dokumentasjon", "nav_topics_title": "Abonnerte emner", - "alert_grant_title": "Merknader er avskrudd", + "alert_notification_permission_required_title": "Merknader er avskrudd", "alert_not_supported_title": "Merknader støttes ikke", "notifications_copied_to_clipboard": "Kopiert til utklippstavlen", "notifications_attachment_copy_url_title": "Kopier vedleggsnettadresse til utklippstavlen", @@ -90,7 +90,7 @@ "prefs_users_dialog_title_edit": "Rediger bruker", "prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh", "prefs_users_dialog_password_label": "Passord", - "prefs_users_dialog_button_save": "Lagre", + "common_save": "Lagre", "prefs_appearance_title": "Utseende", "prefs_appearance_language_title": "Språk", "prefs_users_dialog_username_label": "Brukernavn, f.eks. phil", @@ -98,11 +98,11 @@ "priority_default": "forvalg", "priority_high": "høy", "priority_max": "maks.", - "alert_grant_button": "Innvilg nå", + "alert_notification_permission_required_button": "Innvilg nå", "publish_dialog_topic_label": "Emnenavn", "prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag", "notifications_click_copy_url_button": "Kopier lenke", - "error_boundary_title": "Oida. Ntfy krasjet.", + "error_boundary_title": "Oida, ntfy krasjet", "publish_dialog_message_placeholder": "Skriv en melding her", "publish_dialog_button_cancel": "Avbryt", "prefs_notifications_min_priority_title": "Minimumsprioritet", @@ -113,14 +113,87 @@ "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke", "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned", "priority_min": "min.", - "subscribe_dialog_login_button_back": "Tilbake", + "common_back": "Tilbake", "prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_users_table_base_url_header": "Tjeneste-nettadresse", - "prefs_users_dialog_button_cancel": "Avbryt", - "prefs_users_dialog_button_add": "Legg til", - "publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse", + "common_cancel": "Avbryt", + "common_add": "Legg til", + "publish_dialog_chip_attach_url_label": "Legg til fil med nettadresse", "publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi", - "prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas", + "prefs_notifications_sound_description_none": "Merknader spiller ikke lyd når de mottas", "subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler", - "prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere" + "prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere", + "notifications_no_subscriptions_title": "Det ser ut til at du ikke har noen abonnementer ennå.", + "publish_dialog_attachment_limits_file_and_quota_reached": "overskrider {{fileSizeLimit}} filgrense og kvote, {{remainingBytes}} gjenstår", + "publish_dialog_attachment_limits_file_reached": "overskrider filgrensen på {{fileSizeLimit}}", + "publish_dialog_title_label": "Tittel", + "publish_dialog_title_placeholder": "Varslingstittel, f.eks. Diskplassvarsel", + "publish_dialog_topic_placeholder": "Emnenavn, f.eks. halgeir_varsler", + "publish_dialog_chip_click_label": "Klikk URL", + "publish_dialog_chip_delay_label": "Forsink leveringen", + "publish_dialog_details_examples_description": "For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se dokumentasjonen.", + "publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com", + "alert_notification_permission_required_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.", + "alert_not_supported_description": "Varsler støttes ikke i nettleseren din.", + "notifications_attachment_file_app": "Android-app-fil", + "notifications_no_subscriptions_description": "Klikk på \"{{linktext}}\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.", + "notifications_actions_http_request_title": "Send HTTP {{metode}} til {{url}}", + "notifications_none_for_any_description": "For å sende varsler til et emne, bare PUT eller POST til emne-URLen. Her er et eksempel som bruker et av emnene dine.", + "notifications_more_details": "For mer informasjon, sjekk ut nettstedet eller dokumentasjonen.", + "publish_dialog_attachment_limits_quota_reached": "overskrider kvoten, {{remainingBytes}} gjenstår", + "publish_dialog_click_reset": "Fjern klikk-URL", + "publish_dialog_delay_placeholder": "Forsinket levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (bare på engelsk)", + "emoji_picker_search_clear": "Tøm søk", + "subscribe_dialog_subscribe_description": "Det kan hende emner ikke er passordsbeskyttet, så velg et navn som ikke er enkelt å gjette. Når du har abonnert kan du utføre PUT/POST av merknader.", + "publish_dialog_checkbox_publish_another": "Publiser enda en", + "subscribe_dialog_login_description": "Dette emnet er passordbeskyttet. Vennligst skriv inn brukernavn og passord for å abonnere.", + "prefs_notifications_sound_play": "Spill av valgt lyd", + "subscribe_dialog_error_user_not_authorized": "Bruker {{brukernavn}} ikke autorisert", + "prefs_users_delete_button": "Slett bruker", + "error_boundary_unsupported_indexeddb_description": "ntfy-nettappen trenger IndexedDB for å fungere, og nettleseren din støtter ikke IndexedDB i privat nettlesingsmodus.

Selv om dette er uheldig, gir det heller ikke så mye mening å bruke ntfy-nettappen i privat surfemodus uansett, fordi alt er lagret i nettleserlagringen. Du kan lese mer om det i denne GitHub-feilmeldingen, eller snakk med oss på Discord eller Matrix.", + "action_bar_show_menu": "Vis meny", + "action_bar_toggle_mute": "Aktiver/deaktiver notifikasjoner", + "prefs_notifications_min_priority_description_max": "Vis merknader hvis prioritet er 5 (maks.)", + "prefs_notifications_min_priority_any": "Hvilken som helst prioritet", + "prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere", + "prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.", + "error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.
Hvis du har et minutt, vennligst rapporter dette på GitHub, eller gi oss beskjed via Discord eller Matrix.", + "action_bar_logo_alt": "ntfy logo", + "message_bar_publish": "Publiser melding", + "action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny", + "message_bar_show_dialog": "Vis publiseringsdialog", + "nav_button_muted": "Varsler dempet", + "nav_button_connecting": "kobler til", + "notifications_list": "Varslingsliste", + "notifications_list_item": "Varsling", + "notifications_mark_read": "Merk som lest", + "notifications_delete": "Slett", + "notifications_priority_x": "Prioritet {{prioritet}}", + "notifications_new_indicator": "Nytt varsel", + "notifications_attachment_image": "Vedlagt bilde", + "notifications_attachment_file_image": "bildefil", + "notifications_attachment_file_video": "videofil", + "notifications_attachment_file_audio": "lydfil", + "notifications_attachment_file_document": "annet dokument", + "notifications_actions_not_supported": "Handling støttes ikke i nettappen", + "notifications_none_for_topic_description": "For å sende varsler til dette emnet, bare PUT eller POST til emne-URLen.", + "publish_dialog_emoji_picker_show": "Velg emoji", + "publish_dialog_topic_reset": "Tilbakestill emne", + "publish_dialog_click_label": "Klikk URL", + "publish_dialog_email_reset": "Fjern videresending av e-post", + "publish_dialog_attach_reset": "Fjern URL vedlegg", + "publish_dialog_delay_reset": "Fjern forsinket levering", + "publish_dialog_attached_file_remove": "Fjern vedlagt fil", + "subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL", + "prefs_users_table": "Brukertabell", + "prefs_users_edit_button": "Rediger bruker", + "error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke", + "action_bar_account": "Konto", + "action_bar_profile_settings": "Innstillinger", + "nav_button_account": "Konto", + "signup_title": "Opprett en ntfy konto", + "signup_form_username": "Brukernavn", + "signup_form_password": "Passord", + "signup_form_button_submit": "Meld deg på", + "signup_form_confirm_password": "Bekreft passord" } diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index 0967ef42..65bd8eee 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -1 +1,384 @@ -{} +{ + "action_bar_settings": "Instellingen", + "action_bar_send_test_notification": "Verstuur testnotificatie", + "action_bar_clear_notifications": "Wis alle notificaties", + "message_bar_type_message": "Typ hier een bericht", + "action_bar_unsubscribe": "Afmelden", + "message_bar_error_publishing": "Fout bij publiceren notificatie", + "nav_topics_title": "Geabonneerde onderwerpen", + "nav_button_settings": "Instellingen", + "alert_not_supported_description": "Notificaties worden niet ondersteund door je browser.", + "notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.", + "publish_dialog_tags_label": "Tags", + "publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen", + "prefs_users_dialog_title_edit": "Gebruiker bewerken", + "error_boundary_title": "Oh nee, ntfy is vastgelopen", + "error_boundary_description": "Dit hoort natuurlijk niet te gebeuren. Onze excuses.
Wanneer het mogelijk is, meld deze fout op GitHub, of laat het ons weten via Discord of Matrix.", + "error_boundary_button_copy_stack_trace": "Stack trace kopiëren", + "error_boundary_stack_trace": "Stacktrace", + "error_boundary_gathering_info": "Meer informatie verzamelen …", + "prefs_users_delete_button": "Gebruiker verwijderen", + "prefs_notifications_delete_after_one_week": "Na één week", + "prefs_notifications_delete_after_one_month": "Na één maand", + "prefs_users_dialog_title_add": "Gebruiker toevoegen", + "prefs_users_dialog_password_label": "Wachtwoord", + "error_boundary_unsupported_indexeddb_description": "De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.

Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen in deze GitHub issue, of praat met ons op Discord of Matrix.", + "action_bar_show_menu": "Toon menu", + "action_bar_logo_alt": "ntfy logo", + "action_bar_toggle_mute": "Notificaties dempen/opheffen", + "action_bar_toggle_action_menu": "Open/Sluit actiemenu", + "message_bar_show_dialog": "Toon publicatie venster", + "message_bar_publish": "Bericht publiceren", + "nav_button_all_notifications": "Alle notificaties", + "nav_button_documentation": "Documentatie", + "nav_button_publish_message": "Notificatie publiceren", + "nav_button_subscribe": "Abonneer op onderwerp", + "nav_button_muted": "Notificaties gedempt", + "nav_button_connecting": "verbinden", + "alert_notification_permission_required_title": "Notificaties zijn uitgeschakeld", + "alert_notification_permission_required_description": "Verleen je browser toestemming voor het weergeven van notificaties.", + "alert_notification_permission_required_button": "Nu toestaan", + "alert_not_supported_title": "Notificaties zijn niet ondersteund", + "notifications_list": "Notificatielijst", + "notifications_list_item": "Notificatie", + "notifications_mark_read": "Markeer als gelezen", + "notifications_delete": "Verwijder", + "notifications_copied_to_clipboard": "Gekopieerd naar klembord", + "notifications_tags": "Labels", + "notifications_priority_x": "Prioriteit {{priority}}", + "notifications_new_indicator": "Nieuwe notificatie", + "notifications_attachment_image": "Afbeelding bijlage", + "notifications_attachment_copy_url_title": "Kopieer URL van bijlage naar klembord", + "notifications_attachment_copy_url_button": "URL kopiëren", + "notifications_attachment_open_title": "Ga naar {{url}}", + "notifications_attachment_open_button": "Bijlage openen", + "notifications_attachment_link_expires": "link vervalt op {{date}}", + "notifications_attachment_link_expired": "download link is verlopen", + "notifications_attachment_file_image": "afbeeldingsbestand", + "notifications_attachment_file_video": "videobestand", + "notifications_attachment_file_audio": "audiobestand", + "notifications_attachment_file_app": "Android app bestand", + "notifications_attachment_file_document": "overig document", + "notifications_click_copy_url_title": "link URL naar klembord kopiëren", + "notifications_click_copy_url_button": "Link kopiëren", + "notifications_click_open_button": "Link openen", + "notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.", + "notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp. Hier is een voorbeeld met één van je onderwerpen.", + "notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.", + "notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.", + "notifications_example": "Voorbeeld", + "notifications_more_details": "Voor meer informatie, bezoek de website of documentatie.", + "notifications_loading": "Notificaties laden …", + "publish_dialog_title_topic": "Publiceren naar {{topic}}", + "publish_dialog_title_no_topic": "Notificatie publiceren", + "publish_dialog_progress_uploading": "Uploaden …", + "notifications_actions_open_url_title": "Ga naar {{url}}", + "notifications_actions_not_supported": "Actie wordt niet ondersteund in de webapplicatie", + "notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}", + "notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.", + "publish_dialog_priority_low": "Lage prioriteit", + "publish_dialog_progress_uploading_detail": "Uploaden {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Notificatie gepubliceerd", + "publish_dialog_attachment_limits_file_and_quota_reached": "overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend", + "publish_dialog_attachment_limits_file_reached": "overschrijd {{fileSizeLimit}} bestandslimiet", + "publish_dialog_priority_default": "Standaard prioriteit", + "publish_dialog_attachment_limits_quota_reached": "overschrijd quotum, {{remainingBytes}} resterend", + "publish_dialog_emoji_picker_show": "Kies een emoji", + "publish_dialog_priority_high": "Hoge prioriteit", + "publish_dialog_priority_max": "Maximale prioriteit", + "publish_dialog_priority_min": "Minimale prioriteit", + "publish_dialog_base_url_label": "Service URL", + "publish_dialog_base_url_placeholder": "Service URL, bijvoorbeeld: https://voorbeeld.com", + "publish_dialog_topic_label": "Onderwerp", + "publish_dialog_topic_placeholder": "Onderwerp, bijv. phil_alerts", + "publish_dialog_topic_reset": "Onderwerp resetten", + "publish_dialog_title_label": "Titel", + "publish_dialog_title_placeholder": "Notificatie titel , bijv. Schijfruimte alarm", + "publish_dialog_message_label": "Bericht", + "publish_dialog_message_placeholder": "Typ hier een bericht", + "publish_dialog_tags_placeholder": "Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup", + "publish_dialog_priority_label": "Prioriteit", + "publish_dialog_click_label": "Klik URL", + "publish_dialog_click_reset": "Verwijder klik URL", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com", + "publish_dialog_email_reset": "Email doorsturen verwijderen", + "publish_dialog_attach_label": "URL van bijlage", + "publish_dialog_click_placeholder": "URL die geopend zal worden wanneer op de notificatie geklikt wordt", + "publish_dialog_attach_placeholder": "Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Bijlage URL verwijderen", + "publish_dialog_filename_label": "Bestandsnaam", + "publish_dialog_filename_placeholder": "Bestandsnaam van bijlage", + "publish_dialog_delay_label": "Uitstellen", + "publish_dialog_delay_placeholder": "Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \"{{naturalLanguage}}\" (alleen Engels)", + "publish_dialog_delay_reset": "Verwijder uitgestelde bezorging", + "publish_dialog_other_features": "Andere functionaliteiten:", + "publish_dialog_chip_click_label": "Klik URL", + "publish_dialog_chip_email_label": "Doorsturen naar email", + "publish_dialog_chip_attach_url_label": "Bestand bijvoegen via URL", + "publish_dialog_chip_delay_label": "Uitgestelde bezorging", + "publish_dialog_chip_topic_label": "Onderwerp veranderen", + "publish_dialog_details_examples_description": "Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de documentatie.", + "publish_dialog_button_cancel_sending": "Versturen annuleren", + "publish_dialog_button_cancel": "Annuleer", + "publish_dialog_button_send": "Verstuur", + "publish_dialog_checkbox_publish_another": "Nog een bericht versturen", + "publish_dialog_attached_file_title": "Bijgevoegd bestand:", + "publish_dialog_attached_file_filename_placeholder": "Bijlage bestandsnaam", + "publish_dialog_attached_file_remove": "Verwijder bijgevoegd bestand", + "publish_dialog_drop_file_here": "Bestand hier slepen", + "emoji_picker_search_placeholder": "Emoji zoeken", + "emoji_picker_search_clear": "Zoeken leegmaken", + "subscribe_dialog_subscribe_topic_placeholder": "Onderwerp naam, bijv. phils_waarschuwingen", + "subscribe_dialog_subscribe_use_another_label": "Gebruik een andere server", + "subscribe_dialog_subscribe_base_url_label": "Service URL", + "subscribe_dialog_subscribe_button_cancel": "Annuleren", + "subscribe_dialog_subscribe_button_subscribe": "Abonneren", + "subscribe_dialog_login_title": "Aanmelding vereist", + "subscribe_dialog_login_description": "Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.", + "subscribe_dialog_login_username_label": "Gebruikersnaam, bijv. phil", + "subscribe_dialog_subscribe_title": "Onderwerp abonneren", + "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", + "subscribe_dialog_login_password_label": "Wachtwoord", + "common_back": "Terug", + "subscribe_dialog_login_button_login": "Aanmelden", + "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", + "subscribe_dialog_error_user_anonymous": "anoniem", + "prefs_notifications_title": "Notificaties", + "prefs_notifications_sound_title": "Meldingsgeluid", + "prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven", + "prefs_notifications_sound_play": "Geselecteerd geluid afspelen", + "prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} geluid afspelen", + "prefs_notifications_sound_no_sound": "Geen geluid", + "prefs_notifications_min_priority_title": "Minimale prioriteit", + "prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit", + "prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit {{number}} ({{name}}) is of hoger", + "prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit 5 (maximaal) is", + "prefs_notifications_min_priority_any": "Elke prioriteit", + "prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger", + "prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger", + "prefs_notifications_min_priority_high_and_higher": "Hoge prioriteit en hoger", + "prefs_notifications_min_priority_max_only": "Alleen maximale prioriteit", + "prefs_notifications_delete_after_title": "Notificaties verwijderen", + "prefs_notifications_delete_after_never": "Nooit", + "prefs_notifications_delete_after_three_hours": "Na drie uur", + "prefs_notifications_delete_after_one_day": "Na één dag", + "prefs_notifications_delete_after_never_description": "Notificaties worden nooit automatisch verwijderd", + "prefs_notifications_delete_after_three_hours_description": "Notificaties worden na drie uur automatisch verwijderd", + "prefs_notifications_delete_after_one_day_description": "Notificaties worden na één dag automatisch verwijderd", + "prefs_notifications_delete_after_one_week_description": "Notificaties worden na één week automatisch verwijderd", + "prefs_notifications_delete_after_one_month_description": "Notificaties worden na één maand automatisch verwijderd", + "prefs_users_title": "Gebruikers beheren", + "prefs_users_description": "Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.", + "prefs_users_table": "Gebruikerstabel", + "prefs_users_add_button": "Gebruiker toevoegen", + "prefs_users_edit_button": "Gebruiker bewerken", + "prefs_users_table_user_header": "Gebruiker", + "prefs_users_table_base_url_header": "Service URL", + "prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh", + "prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil", + "common_cancel": "Annuleren", + "common_add": "Toevoegen", + "common_save": "Bewaren", + "prefs_appearance_title": "Weergave", + "prefs_appearance_language_title": "Taal", + "priority_min": "min", + "priority_low": "laag", + "priority_default": "standaard", + "priority_high": "hoog", + "priority_max": "max", + "error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund", + "signup_form_username": "Gebruikersnaam", + "signup_form_toggle_password_visibility": "Wachtwoord zichtbaar maken", + "signup_already_have_account": "Heb je al een account? Log in!", + "signup_form_button_submit": "Registreer", + "signup_disabled": "Registreren is uitgeschakeld", + "signup_error_username_taken": "Gebruikersnaam {{username}} is al bezet", + "signup_error_creation_limit_reached": "Limiet voor aanmaken account bereikt", + "login_title": "Aanmelden bij uw ntfy account", + "login_form_button_submit": "Inloggen", + "login_link_signup": "Registreer", + "login_disabled": "Inloggen is uitgeschakeld", + "action_bar_account": "Account", + "action_bar_reservation_add": "Onderwerp reserveren", + "action_bar_reservation_edit": "Reservatie wijzigen", + "action_bar_reservation_delete": "Verwijder reservatie", + "action_bar_reservation_limit_reached": "Limiet bereikt", + "action_bar_profile_title": "Profiel", + "nav_upgrade_banner_label": "Upgrade naar ntfy Pro", + "nav_upgrade_banner_description": "Onderwerpen reserveren, meer berichten & e-mails, en grotere bijlagen", + "alert_not_supported_context_description": "Notificaties worden alleen ondersteund via HTTPS. Dit is een beperking van de Notificaties API.", + "display_name_dialog_placeholder": "Weergavenaam", + "reserve_dialog_checkbox_label": "Onderwerp reserveren en toegang configureren", + "account_basics_title": "Account", + "account_basics_username_title": "Gebruikersnaam", + "account_basics_username_description": "Hé, dat ben jij ❤", + "account_basics_username_admin_tooltip": "Je bent beheerder", + "account_basics_password_title": "Wachtwoord", + "account_basics_password_description": "Wijzig het wachtwoord van je account", + "account_basics_password_dialog_current_password_label": "Huidig wachtwoord", + "account_basics_password_dialog_new_password_label": "Nieuw wachtwoord", + "account_basics_password_dialog_confirm_password_label": "Bevestig wachtwoord", + "account_basics_password_dialog_button_submit": "Wijzig wachtwoord", + "account_basics_password_dialog_current_password_incorrect": "Wachtwoord onjuist", + "account_usage_title": "Gebruik", + "account_usage_of_limit": "van {{limit}}", + "account_usage_unlimited": "Onbeperkt", + "account_basics_tier_title": "Account type", + "account_basics_tier_admin": "Beheerder", + "account_basics_tier_admin_suffix_with_tier": "(met {{tier}} niveau)", + "account_basics_tier_basic": "Basis", + "account_basics_tier_free": "Gratis", + "account_basics_tier_change_button": "Wijzig", + "account_basics_tier_paid_until": "Abonnement betaald tot {{date}}, en wordt automatisch verlengd", + "account_basics_tier_payment_overdue": "Je betaling is te laat. Update je betalingsmethode, anders wordt je account binnenkort gedowngraded.", + "account_basics_tier_canceled_subscription": "Je abonnement is opgezegd en wordt op {{date}} gedowngraded naar een gratis account.", + "signup_form_password": "Wachtwoord", + "signup_title": "Een ntfy account aanmaken", + "signup_form_confirm_password": "Bevestig wachtwoord", + "action_bar_change_display_name": "Weergavenaam wijzigen", + "action_bar_profile_logout": "Uitloggen", + "action_bar_profile_settings": "Instellingen", + "action_bar_sign_up": "Registreer", + "nav_button_account": "Account", + "action_bar_sign_in": "Inloggen", + "display_name_dialog_title": "Weergavenaam wijzigen", + "display_name_dialog_description": "Stel een alternatieve naam in voor een onderwerp dat wordt weergeven in de abonnementenlijst. Dit helpt onderwerpen met gecompliceerde namen gemakkelijker te identificeren.", + "subscribe_dialog_subscribe_button_generate_topic_name": "Naam genereren", + "subscribe_dialog_error_topic_already_reserved": "Onderwerp al gereserveerd", + "account_basics_password_dialog_title": "Wijzig wachtwoord", + "account_usage_limits_reset_daily": "Gebruikslimieten worden dagelijks om middernacht (UTC) gereset", + "account_basics_tier_upgrade_button": "Upgrade naar Pro", + "account_upgrade_dialog_title": "Accountniveau wijzigen", + "account_upgrade_dialog_interval_yearly_discount_save": "bespaar {{discount}}%", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} jaarlijks gefactureerd. Bespaar {{save}}.", + "account_upgrade_dialog_cancel_warning": "Hiermee wordt uw abonnement opgezegd en wordt uw account gedowngraded op {{date}}. Op die datum worden onderwerpreserveringen en berichten in de cache op de server verwijderd .", + "account_tokens_dialog_button_update": "Token bijwerken", + "account_upgrade_dialog_proration_info": "Pro rata: Bij een upgrade tussen betaalde abonnementen wordt het prijsverschil onmiddellijk in rekening gebracht. Wanneer u downgradet naar een lager niveau, wordt het saldo gebruikt om toekomstige factureringsperioden te betalen.", + "account_upgrade_dialog_reservations_warning_one": "Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, , moet u ten minste één reservering verwijderen . U kunt reserveringen verwijderen in de Instellingen.", + "account_upgrade_dialog_reservations_warning_other": "Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, moet u ten minste {{count}} reserveringen verwijderen. U kunt reserveringen verwijderen in de Instellingen.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} gereserveerde onderwerpen", + "account_upgrade_dialog_billing_contact_email": "Neem voor vragen over facturering rechtstreeks contact met ons op.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_never_expires": "Verloopt nooit", + "account_tokens_table_current_session": "Huidige browsersessie", + "prefs_reservations_table_everyone_read_only": "Ik kan publiceren en abonneren, iedereen kan zich abonneren", + "prefs_reservations_table_everyone_write_only": "Ik kan publiceren en abonneren, iedereen kan publiceren", + "account_usage_reservations_none": "Geen gereserveerde onderwerpen voor dit account", + "account_usage_attachment_storage_title": "Bijlage-opslag", + "account_usage_attachment_storage_description": "{{filesize}} per bestand, verwijderd na {{expiry}}", + "account_delete_dialog_description": "Hiermee wordt uw account definitief verwijderd, inclusief alle gegevens die op de server zijn opgeslagen. Na verwijdering is uw gebruikersnaam 7 dagen niet beschikbaar. Als u echt wilt doorgaan, bevestig dan met uw wachtwoord in het onderstaande vak.", + "account_delete_dialog_billing_warning": "Als u uw account verwijdert, wordt ook uw facturering onmiddellijk geannuleerd. U heeft dan geen toegang meer tot het factureringsdashboard.", + "account_tokens_dialog_button_cancel": "Annuleren", + "reservation_delete_dialog_submit_button": "Reservering verwijderen", + "prefs_reservations_table_everyone_deny_all": "Alleen ik kan publiceren en abonneren", + "reservation_delete_dialog_description": "Het verwijderen van een reservering geeft het eigendom van het onderwerp op en stelt anderen in staat het te reserveren. U kunt bestaande berichten en bijlagen behouden of verwijderen.", + "account_basics_tier_interval_monthly": "maandelijks", + "account_basics_tier_interval_yearly": "jaarlijks", + "account_usage_basis_ip_description": "Gebruiksstatistieken en -limieten voor dit account zijn gebaseerd op uw IP-adres en kunnen dus worden gedeeld met andere gebruikers. De hierboven weergegeven limieten zijn bij benadering gebaseerd op de bestaande limieten.", + "account_usage_cannot_create_portal_session": "Kan factureringsportaal niet openen", + "account_delete_title": "Account verwijderen", + "account_delete_description": "Verwijder uw account definitief", + "account_delete_dialog_label": "Wachtwoord", + "account_delete_dialog_button_cancel": "Annuleren", + "account_delete_dialog_button_submit": "Verwijder uw account definitief", + "account_upgrade_dialog_interval_monthly": "Maandelijks", + "account_upgrade_dialog_interval_yearly": "Jaarlijks", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "bespaar tot {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "Geen gereserveerde onderwerpen", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} totale opslag", + "account_upgrade_dialog_tier_current_label": "Huidig", + "account_upgrade_dialog_button_update_subscription": "Abonnement bijwerken", + "account_tokens_title": "Toegangstokens", + "account_tokens_description": "Gebruik toegangstokens bij het publiceren en abonneren via de ntfy API, zodat u uw accountgegevens niet hoeft op te sturen. Bekijk de documentatie voor meer informatie.", + "account_tokens_table_label_header": "Label", + "account_tokens_table_cannot_delete_or_edit": "Kan huidige sessietoken niet bewerken of verwijderen", + "account_tokens_dialog_expires_label": "Toegangstoken verloopt over", + "account_tokens_dialog_expires_unchanged": "Vervaldatum ongewijzigd laten", + "account_tokens_dialog_expires_x_hours": "Token verloopt over {{hours}} uur", + "account_tokens_dialog_expires_x_days": "Token verloopt over {{days}} dagen", + "account_tokens_dialog_expires_never": "Token verloopt nooit", + "account_tokens_delete_dialog_title": "Toegangstoken verwijderen", + "account_tokens_delete_dialog_description": "Voordat u een toegangstoken verwijdert, moet u ervoor zorgen dat er geen toepassingen of scripts actief gebruik van maken. Deze actie kan niet ongedaan worden gemaakt.", + "prefs_users_table_cannot_delete_or_edit": "Kan ingelogde gebruiker niet verwijderen of bewerken", + "prefs_reservations_title": "Gereserveerde onderwerpen", + "prefs_reservations_description": "U kunt hier onderwerpnamen reserveren voor persoonlijk gebruik. Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.", + "prefs_reservations_limit_reached": "Je hebt je limiet voor gereserveerde onderwerpen bereikt.", + "prefs_reservations_add_button": "Gereserveerd onderwerp toevoegen", + "prefs_reservations_table_click_to_subscribe": "Klik om je te abonneren", + "prefs_reservations_dialog_title_add": "Onderwerp reserveren", + "prefs_reservations_dialog_title_edit": "Gereserveerd onderwerp bewerken", + "prefs_reservations_dialog_title_delete": "Onderwerpreservering verwijderen", + "prefs_reservations_dialog_description": "Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.", + "prefs_reservations_dialog_topic_label": "Onderwerp", + "prefs_reservations_dialog_access_label": "Toegang", + "reservation_delete_dialog_action_keep_title": "Bewaar in de cache opgeslagen berichten en bijlagen", + "reservation_delete_dialog_action_keep_description": "Berichten en bijlagen die in de cache op de server zijn opgeslagen, worden publiekelijk zichtbaar voor mensen die de onderwerpnaam kennen.", + "reservation_delete_dialog_action_delete_description": "Berichten en bijlagen in de cache worden permanent verwijderd. Deze actie kan niet ongedaan gemaakt worden.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} gereserveerd onderwerp", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} dagelijks bericht", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagelijkse berichten", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagelijkse e-mail", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} dagelijkse e-mails", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per bestand", + "account_upgrade_dialog_tier_price_per_month": "maand", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per jaar. Maandelijks gefactureerd.", + "account_upgrade_dialog_tier_selected_label": "Geselecteerd", + "account_upgrade_dialog_billing_contact_website": "Raadpleeg voor vragen over facturering onze website.", + "account_upgrade_dialog_button_cancel": "Annuleren", + "account_upgrade_dialog_button_redirect_signup": "Nu aanmelden", + "account_upgrade_dialog_button_pay_now": "Nu betalen en inschrijven", + "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", + "account_tokens_table_last_access_header": "Laatste toegang", + "account_tokens_table_expires_header": "Verloopt op", + "common_copy_to_clipboard": "Kopieer naar klembord", + "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd", + "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen", + "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", + "reservation_delete_dialog_action_delete_title": "Verwijder in de cache opgeslagen berichten en bijlagen", + "account_basics_tier_description": "Het niveau van uw account", + "account_basics_tier_admin_suffix_no_tier": "(geen niveau)", + "account_basics_tier_manage_billing_button": "Facturering beheren", + "account_usage_messages_title": "Gepubliceerde berichten", + "account_usage_emails_title": "E-mails verzonden", + "account_usage_reservations_title": "Gereserveerde onderwerpen", + "account_tokens_table_create_token_button": "Toegangstoken maken", + "account_tokens_table_last_origin_tooltip": "Vanaf IP-adres {{ip}}, klik om op te zoeken", + "account_tokens_dialog_title_create": "Toegangstoken maken", + "account_tokens_dialog_title_edit": "Toegangstoken bewerken", + "account_tokens_dialog_title_delete": "Toegangstoken verwijderen", + "account_tokens_dialog_label": "Label, bijv. Radarr-meldingen", + "account_tokens_dialog_button_create": "Token maken", + "prefs_reservations_edit_button": "Onderwerptoegang bewerken", + "prefs_reservations_delete_button": "Toegang tot onderwerp resetten", + "prefs_reservations_table": "Tabel met gereserveerde onderwerpen", + "prefs_reservations_table_topic_header": "Onderwerp", + "prefs_reservations_table_access_header": "Toegang", + "prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren", + "prefs_reservations_table_not_subscribed": "Niet geabonneerd", + "publish_dialog_call_label": "Telefoongesprek", + "publish_dialog_call_reset": "Telefoongesprek verwijderen", + "publish_dialog_chip_call_label": "Telefoongesprek", + "account_basics_phone_numbers_title": "Telefoonnummers", + "account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken", + "account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers", + "account_basics_phone_numbers_dialog_verify_button_call": "Bel me", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes", + "account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord", + "publish_dialog_call_item": "Bel telefoonnummer {{nummer}}", + "account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers", + "account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek", + "account_basics_phone_numbers_dialog_number_label": "Telefoonnummer", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes", + "account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes", + "account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.", + "account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen", + "account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS", + "account_basics_phone_numbers_dialog_code_label": "Verificatiecode", + "account_usage_calls_title": "Aantal telefoontjes", + "account_usage_calls_none": "Met dit account kan niet worden gebeld" +} diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json new file mode 100644 index 00000000..8733345c --- /dev/null +++ b/web/public/static/langs/pl.json @@ -0,0 +1,321 @@ +{ + "action_bar_send_test_notification": "Wyślij powiadomienie testowe", + "action_bar_clear_notifications": "Wyczyść powiadomienia", + "action_bar_toggle_mute": "Włączanie/wyłączanie wyciszania powiadomień", + "action_bar_toggle_action_menu": "Otwórz/zamknij menu działań", + "message_bar_type_message": "Wpisz wiadomość tutaj", + "message_bar_error_publishing": "Błąd przy wysyłaniu powiadomienia", + "message_bar_show_dialog": "Pokaż okno dialogowe publikacji", + "nav_button_all_notifications": "Wszystkie powiadomienia", + "nav_button_documentation": "Dokumentacja", + "nav_button_muted": "Powiadomienia wyciszone", + "alert_notification_permission_required_title": "Powiadomienia są wyłączone", + "alert_notification_permission_required_description": "Udziel przeglądarce pozwolenia na wyświetlanie powiadomień na pulpicie.", + "alert_notification_permission_required_button": "Pozwól teraz", + "alert_not_supported_title": "Powiadomienia nie są obsługiwane", + "alert_not_supported_description": "Powiadomienia nie są obsługiwane przez Twoją przeglądarkę.", + "notifications_list": "Lista powiadomień", + "notifications_list_item": "Powiadomienie", + "notifications_mark_read": "Oznacz jako przeczytane", + "notifications_delete": "Usuń", + "notifications_copied_to_clipboard": "Skopiowano do schowka", + "notifications_tags": "Tagi", + "message_bar_publish": "Opublikuj powiadomienie", + "nav_topics_title": "Subskrybowane tematy", + "nav_button_settings": "Ustawienia", + "nav_button_publish_message": "Opublikuj powiadomienie", + "nav_button_subscribe": "Zasubskrybuj temat", + "nav_button_connecting": "łączenie", + "notifications_attachment_image": "Obraz załącznika", + "notifications_attachment_copy_url_button": "Kopiuj Adres URL", + "notifications_attachment_link_expires": "Łącze wygasa w dniu {{date}}", + "notifications_attachment_link_expired": "Łącze do pobrania wygasło", + "notifications_attachment_file_image": "plik graficzny", + "notifications_attachment_file_video": "plik wideo", + "notifications_attachment_file_audio": "plik audio", + "notifications_attachment_file_app": "plik aplikacji Android", + "notifications_attachment_file_document": "inny dokument", + "notifications_click_copy_url_title": "Skopiuj adres URL do schowka", + "notifications_click_open_button": "Otwórz łącze", + "notifications_actions_open_url_title": "Przejdź do {{url}}", + "notifications_actions_not_supported": "Ta akcja nie jest obsługiwana w aplikacji internetowej", + "notifications_actions_http_request_title": "Wyślij HTTP {{method}} do {{url}}", + "notifications_none_for_topic_title": "Nie otrzymałeś jeszcze żadnych powiadomień dla tego tematu.", + "notifications_none_for_any_description": "Aby wysłać powiadomienia do tematu, wyślij PUT/POST do adresu URL tematu. Oto przykład z jednym z twoich tematów.", + "notifications_no_subscriptions_title": "Wygląda na to, że nie masz jeszcze żadnych subskrypcji.", + "notifications_no_subscriptions_description": "Kliknij łącze \"{{linktext}}\", aby stworzyć lub zasubskrybować temat. Następnie możesz wysyłać wiadomości za pomocą PUT lub POST i otrzymywać powiadomienia tutaj.", + "notifications_example": "Przykład", + "notifications_loading": "Ładowanie powiadomień …", + "publish_dialog_title_topic": "Opublikuj do {{topic}}", + "publish_dialog_title_no_topic": "Opublikuj powiadomienie", + "publish_dialog_progress_uploading": "Przesyłanie …", + "publish_dialog_progress_uploading_detail": "Przesyłanie {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Powiadomienie wysłane", + "publish_dialog_attachment_limits_file_and_quota_reached": "przekracza limit rozmiaru pliku {{fileSizeLimit}}, pozostaje {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "przekracza limit rozmiaru pliku {{filesizeLimit}}", + "publish_dialog_attachment_limits_quota_reached": "przekracza limit, {{remainingBytes}} pozostało", + "publish_dialog_emoji_picker_show": "Wybierz emotkę", + "publish_dialog_priority_min": "Min. priorytet", + "publish_dialog_priority_low": "Niski priorytet", + "publish_dialog_base_url_label": "Adres URL usługi", + "publish_dialog_base_url_placeholder": "Adres URL usługi, np. https://example.com", + "publish_dialog_topic_label": "Nazwa tematu", + "publish_dialog_topic_placeholder": "Nazwa tematu, np. moje_alerty", + "publish_dialog_topic_reset": "Resetuj temat", + "publish_dialog_title_label": "Tytuł", + "publish_dialog_title_placeholder": "Tytuł notyfikacji, np. Niski poziom baterrii", + "publish_dialog_message_label": "Wiadomość", + "publish_dialog_message_placeholder": "Wpisz wiadomość tutaj", + "publish_dialog_tags_label": "Tagi", + "publish_dialog_tags_placeholder": "Lista tagów oddzielona przecinkami, np. ostrzeżenie, srv1-backup", + "publish_dialog_priority_label": "Priorytet", + "publish_dialog_click_label": "Kliknij Adres URL", + "publish_dialog_click_placeholder": "Adres URL, który ma być otwarty po kliknięciu na powiadomienie", + "publish_dialog_click_reset": "Usuń adres URL kliknięcia", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Adres, na który ma być wysłane powiadomienie, np. phil@example.com", + "publish_dialog_email_reset": "Usuń przekazywanie wiadomości email", + "publish_dialog_attach_label": "Adres URL załącznika", + "publish_dialog_attach_placeholder": "Dołączenie pliku z adresu URL, np. https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Usuń adres URL załącznika", + "publish_dialog_filename_label": "Nazwa pliku", + "publish_dialog_filename_placeholder": "Nazwa pliku załącznika", + "publish_dialog_delay_label": "Opóźnienie", + "publish_dialog_delay_reset": "Usuń opóźnione dostarczenie", + "publish_dialog_other_features": "Inne funkcje:", + "publish_dialog_chip_click_label": "Adres URL kliknięcia", + "publish_dialog_chip_email_label": "Przekaż na email", + "publish_dialog_chip_attach_url_label": "Dołącz plik z adresu URL", + "publish_dialog_chip_attach_file_label": "Dołącz plik lokalny", + "publish_dialog_chip_delay_label": "Opóźnienie dostawy", + "publish_dialog_chip_topic_label": "Zmień temat", + "publish_dialog_details_examples_description": "Przykłady i szczegółowe informacje na temat wszystkich opcji można znaleźć w dokumentacji.", + "publish_dialog_button_cancel_sending": "Anuluj wysyłanie", + "publish_dialog_button_send": "Wyślij", + "publish_dialog_checkbox_publish_another": "Wyślij kolejną wiadomość", + "publish_dialog_attached_file_title": "Załączony plik:", + "publish_dialog_attached_file_filename_placeholder": "Nazwa pliku załącznika", + "publish_dialog_drop_file_here": "Upuść plik tutaj", + "emoji_picker_search_placeholder": "Szukaj emotki", + "emoji_picker_search_clear": "Wyczyść wyszukiwanie", + "subscribe_dialog_subscribe_title": "Zasubskrybuj temat", + "subscribe_dialog_subscribe_topic_placeholder": "Nazwa tematu, np. moje_alerty", + "subscribe_dialog_subscribe_use_another_label": "Użyj innego serwera", + "subscribe_dialog_subscribe_base_url_label": "Adres URL usługi", + "subscribe_dialog_subscribe_button_cancel": "Anuluj", + "subscribe_dialog_login_description": "Ten temat jest chroniony hasłem. Proszę podać nazwę użytkownika i hasło, aby zasubskrybować.", + "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", + "subscribe_dialog_login_password_label": "Hasło", + "publish_dialog_button_cancel": "Anuluj", + "common_back": "Powrót", + "subscribe_dialog_login_button_login": "Zaloguj się", + "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", + "subscribe_dialog_error_user_anonymous": "anonim", + "prefs_notifications_title": "Powiadomienia", + "prefs_notifications_sound_title": "Dźwięk powiadomienia", + "prefs_notifications_sound_description_none": "Brak dźwięku po otrzymaniu powiadomienia", + "prefs_notifications_sound_description_some": "Odtwarzaj dźwięk {{sound}}, gdy nadejdzie powiadomienie", + "prefs_notifications_sound_play": "Odtwórz wybrany dźwięk", + "prefs_notifications_min_priority_title": "Minimalny priorytet", + "prefs_notifications_min_priority_description_any": "Pokaż wszystkie powiadomienia, niezależnie od priorytetu", + "prefs_notifications_min_priority_description_x_or_higher": "Pokazuj powiadomienia, gdy ich priorytet to {{number}} ({{name}}) lub wyższy", + "prefs_notifications_min_priority_description_max": "Pokaż powiadomienia, jeśli priorytet wynosi 5 (max)", + "prefs_notifications_min_priority_any": "Dowolny priorytet", + "prefs_notifications_min_priority_low_and_higher": "Niski priorytet i wyższy", + "prefs_notifications_min_priority_default_and_higher": "Priorytet standardowy i wyższy", + "prefs_notifications_min_priority_high_and_higher": "Wysoki priorytet i wyższy", + "prefs_notifications_delete_after_one_day": "Po jednym dniu", + "prefs_notifications_delete_after_one_week": "Po tygodniu", + "prefs_notifications_delete_after_one_month": "Po miesiącu", + "prefs_notifications_delete_after_never_description": "Powiadomienia nigdy nie są automatycznie usuwane", + "prefs_notifications_delete_after_three_hours_description": "Powiadomienia są automatycznie usuwane po trzech godzinach", + "prefs_notifications_delete_after_one_day_description": "Powiadomienia są automatycznie usuwane po jednym dniu", + "prefs_notifications_delete_after_one_month_description": "Powiadomienia są automatycznie usuwane po upływie jednego miesiąca", + "prefs_notifications_delete_after_one_week_description": "Powiadomienia są automatycznie usuwane po upływie jedego tygodnia", + "prefs_users_title": "Zarządzaj użytkownikami", + "prefs_users_description": "Dodaj/usuń użytkowników dla tematów chronionych hasłem. Uwaga: Nazwa użytkownika i hasło są przechowywane w lokalnej pamięci przeglądarki.", + "prefs_users_table": "Tabela użytkowników", + "prefs_users_add_button": "Dodaj użytkownika", + "notifications_attachment_open_button": "Otwórz załącznik", + "prefs_users_edit_button": "Edytuj użytkownika", + "prefs_users_delete_button": "Usuń użytkownika", + "prefs_users_table_base_url_header": "Adres URL usługi", + "prefs_users_dialog_title_add": "Dodaj użytkownika", + "common_cancel": "Anuluj", + "common_add": "Dodaj", + "common_save": "Zapisz", + "prefs_appearance_title": "Wygląd", + "prefs_appearance_language_title": "Język", + "error_boundary_title": "Oh nie, ntfy przestało działać", + "error_boundary_description": "Oczywiście, to nie miało się wydarzyć. Bardzo przepraszam za to.
Jeśli masz minutę, proszę zgłoś to na GitHubie, albo daj nam znać przez Discord lub Matrix.", + "error_boundary_button_copy_stack_trace": "Kopiuj stack trace", + "error_boundary_stack_trace": "Stack trace", + "error_boundary_gathering_info": "Zbierz więcej informacji …", + "error_boundary_unsupported_indexeddb_title": "Prywatne karty przeglądarki nie są obsługiwane", + "action_bar_show_menu": "Pokaż menu", + "action_bar_logo_alt": "ntfy logo", + "action_bar_unsubscribe": "Zrezygnuj z subskrypcji", + "notifications_attachment_copy_url_title": "Kopiuj adres URL załącznika do schowka", + "action_bar_settings": "Ustawienia", + "notifications_priority_x": "Priorytet {{priority}}", + "notifications_new_indicator": "Nowe powiadomienie", + "notifications_attachment_open_title": "Przejdź do {{url}}", + "notifications_click_copy_url_button": "Skopiuj łącze", + "notifications_none_for_topic_description": "Aby wysłać powiadomienia do tego tematu, wyślij PUT lub POST-Request na adres URL tematu.", + "notifications_none_for_any_title": "Nie otrzymałeś żadnych powiadomień.", + "notifications_more_details": "Bardziej szczegółowe informacje można znaleźć na stronie internetowej oraz w dokumentacji.", + "publish_dialog_priority_default": "Domyślny priorytet", + "publish_dialog_priority_max": "Max. priorytet", + "publish_dialog_priority_high": "Wysoki priorytet", + "publish_dialog_delay_placeholder": "Opóźnienie dostarczenie, np.{{unixTimestamp}}, {{relativeTime}}, lub \"{{naturalLanguage}}\" (tylko w języku angielskim)", + "subscribe_dialog_subscribe_button_subscribe": "Subskrybuj", + "prefs_users_table_user_header": "Użytkownik", + "publish_dialog_attached_file_remove": "Usuń załączony plik", + "subscribe_dialog_subscribe_description": "Tematy nie mogą być chronione hasłem, więc wybierz trudną do odgadnięcia nazwę. Po zasubskrybowaniu możesz wysyłać powiadomienia poprzez POST/PUT.", + "subscribe_dialog_login_title": "Wymagane jest zalogowanie się", + "prefs_notifications_delete_after_title": "Usuń powiadomienia", + "prefs_users_dialog_password_label": "Hasło", + "priority_low": "niski", + "priority_default": "podstawowy", + "priority_max": "maksymalny", + "prefs_notifications_delete_after_three_hours": "Po trzech godzinach", + "prefs_users_dialog_base_url_label": "Adres URL usługi, np. https://ntfy.sh", + "prefs_notifications_sound_no_sound": "Bez dzwięku", + "prefs_users_dialog_username_label": "Nazwa użytkownika, np. phil", + "priority_high": "wysoki", + "prefs_notifications_min_priority_max_only": "Tylko maksymalny priorytet", + "prefs_notifications_delete_after_never": "Nigdy", + "prefs_users_dialog_title_edit": "Edytuj użytkownika", + "priority_min": "minimum", + "error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.

To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać w tym wydaniu GitHub, lub na czacie w Discord lub Matrix.", + "signup_form_password": "Hasło", + "signup_title": "Załóż konto ntfy", + "signup_error_creation_limit_reached": "Przekroczono limit zakładania kont", + "action_bar_reservation_limit_reached": "Limit wyczerpany", + "display_name_dialog_title": "Zmień wyświetlaną nazwę", + "display_name_dialog_description": "Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.", + "account_basics_title": "Konto", + "account_basics_password_dialog_title": "Zmień hasło", + "signup_form_username": "Nawa użytkownika", + "signup_form_confirm_password": "Powtórz hasło", + "signup_form_button_submit": "Załóż konto", + "signup_form_toggle_password_visibility": "Pokaż lub ukryj hasło", + "signup_already_have_account": "Masz już konto? Zaloguj się!", + "signup_disabled": "Zakładanie kont jest wyłączone", + "signup_error_username_taken": "Nazwa użytkownika {{username}} jest już zajęta", + "login_title": "Zaloguj się do swojego konta ntfy", + "login_form_button_submit": "Zaloguj się", + "login_link_signup": "Załóż konto", + "login_disabled": "Logowanie jet wyłączone", + "action_bar_account": "Konto", + "action_bar_change_display_name": "Zmień wyświetlaną nazwę", + "action_bar_reservation_add": "Zarezerwuj temat", + "action_bar_reservation_edit": "Zmień rezerwację", + "action_bar_reservation_delete": "Usuń rezerwację", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Ustawienia", + "action_bar_profile_logout": "Wyloguj", + "action_bar_sign_in": "Zaloguj", + "action_bar_sign_up": "Załóż konto", + "nav_button_account": "Konto", + "display_name_dialog_placeholder": "Nazwa wyświetlana", + "reserve_dialog_checkbox_label": "Zarezerwuj temat i skonfiguruj dostęp", + "subscribe_dialog_subscribe_button_generate_topic_name": "Wygeneruj nazwę", + "subscribe_dialog_error_topic_already_reserved": "Temat już jest zarezerwowany", + "account_basics_username_title": "Nazwa użytkownika", + "account_basics_username_description": "Hej, to Ty ❤", + "account_basics_username_admin_tooltip": "Jesteś Administratorem", + "account_basics_password_title": "Hasło", + "account_basics_password_description": "Zmień hasło do konta", + "account_basics_password_dialog_current_password_label": "Aktualne hasło", + "account_basics_password_dialog_new_password_label": "Nowe hasło", + "account_basics_password_dialog_confirm_password_label": "Powtórz hasło", + "account_basics_password_dialog_button_submit": "Zmień hasło", + "account_basics_password_dialog_current_password_incorrect": "Błędne hasło", + "account_usage_title": "Użycie", + "account_usage_of_limit": "z {{limit}}", + "account_usage_unlimited": "Bez limitu", + "account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)", + "account_delete_dialog_button_submit": "Nieodwracalnie usuń konto", + "account_upgrade_dialog_tier_features_no_reservations": "Brak rezerwacji tematów", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na plik", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} pamięci łącznie", + "account_upgrade_dialog_tier_price_per_month": "miesiąc", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} na rok. Płatne miesięcznie.", + "account_upgrade_dialog_billing_contact_email": "W razie pytań dotyczących rozliczeń skontaktuj się z nami bezpośrednio.", + "account_upgrade_dialog_billing_contact_website": "W razie pytań dotyczących rozliczeń sprawdź naszą stronę.", + "account_upgrade_dialog_button_cancel_subscription": "Anuluj subskrypcję", + "account_upgrade_dialog_button_update_subscription": "Zmień subskrypcję", + "account_tokens_title": "Tokeny dostępowe", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Etykieta", + "account_tokens_table_last_access_header": "Ostatnie użycie", + "account_tokens_table_expires_header": "Termin ważności", + "account_tokens_table_never_expires": "Bezterminowy", + "account_tokens_table_current_session": "Aktualna sesja przeglądarki", + "common_copy_to_clipboard": "Kopiuj do schowka", + "account_tokens_table_copied_to_clipboard": "Token został skopiowany", + "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", + "account_tokens_table_create_token_button": "Utwórz token dostępowy", + "account_tokens_dialog_label": "Etykieta, np. Powiadomienia Radarr", + "account_tokens_dialog_button_update": "Zmień token", + "account_basics_tier_interval_monthly": "miesięcznie", + "account_basics_tier_interval_yearly": "rocznie", + "account_upgrade_dialog_interval_monthly": "Miesięcznie", + "account_upgrade_dialog_title": "Zmień plan konta", + "account_delete_dialog_description": "Konto, wraz ze wszystkimi związanymi z nim danymi przechowywanymi na serwerze, będzie nieodwracalnie usunięte. Po usunięciu Twoja nazwa użytkownika będzie niedostępna jeszcze przez 7 dni. Jeśli chcesz kontynuować, potwierdź wpisując swoje hasło w polu poniżej.", + "account_delete_dialog_billing_warning": "Usunięcie konta powoduje natychmiastowe anulowanie subskrypcji. Nie będziesz już mieć dostępu do strony z rachunkami.", + "account_upgrade_dialog_interval_yearly": "Rocznie", + "account_upgrade_dialog_interval_yearly_discount_save": "taniej o {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "nawet {{discount}}% taniej", + "account_upgrade_dialog_button_cancel": "Anuluj", + "account_tokens_description": "Używaj tokenów do publikowania wiadomości i subskrybowania tematów przez API ntfy, żeby uniknąć konieczności podawania danych do logowania. Szczegóły znajdziesz w dokumentacji.", + "account_tokens_dialog_title_create": "Utwórz token dostępowy", + "account_tokens_table_last_origin_tooltip": "Z adresu IP {{ip}}, kliknij żeby sprawdzić", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} płatne jednorazowo. Oszczędzasz {{save}}.", + "account_tokens_dialog_title_edit": "Edytuj token dostępowy", + "account_tokens_dialog_title_delete": "Usuń token dostępowy", + "account_tokens_dialog_button_create": "Utwórz token", + "nav_upgrade_banner_label": "Przejdź na ntfy Pro", + "nav_upgrade_banner_description": "Rezerwuj tematy, więcej powiadomień i maili oraz większe załączniki", + "alert_not_supported_context_description": "Powiadomienia działają tylko przez HTTPS. To jest ograniczenie Notifications API.", + "account_basics_tier_canceled_subscription": "Twoja subskrypcja została anulowana i konto zostanie ograniczone do wersji darmowej w dniu {{date}}.", + "account_basics_tier_manage_billing_button": "Zarządzaj rachunkami", + "account_usage_messages_title": "Wysłane wiadomości", + "account_usage_emails_title": "Wysłane maile", + "account_basics_tier_title": "Rodzaj konta", + "account_basics_tier_description": "Mocarność Twojego konta", + "account_basics_tier_admin": "Administrator", + "account_basics_tier_admin_suffix_with_tier": "(plan {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(brak planu)", + "account_basics_tier_basic": "Podstawowe", + "account_basics_tier_free": "Darmowe", + "account_basics_tier_upgrade_button": "Przejdź na Pro", + "account_basics_tier_change_button": "Zmień", + "account_basics_tier_paid_until": "Subskrypcja opłacona do {{date}} i będzie odnowiona automatycznie", + "account_basics_tier_payment_overdue": "Minął termin płatności. Zaktualizuj metodę płatności, w przeciwnym razie Twoje konto wkrótce zostanie ograniczone.", + "account_usage_reservations_title": "Zarezerwowane tematy", + "account_usage_reservations_none": "Brak zarezerwowanych tematów na tym koncie", + "account_usage_attachment_storage_title": "Miejsce na załączniki", + "account_usage_attachment_storage_description": "{{filesize}} na każdy plik, przechowywane przez {{expiry}}", + "account_usage_basis_ip_description": "Statystyki i limity dla tego konta bazują na Twoim adresie IP, więc mogą być współdzielone z innymi użytkownikami. Limity pokazane powyżej to wartości przybliżone bazujące na rzeczywistych limitach.", + "account_usage_cannot_create_portal_session": "Nie można otworzyć portalu z rachunkami", + "account_delete_title": "Usuń konto", + "account_delete_description": "Usuń swoje konto nieodwracalnie", + "account_delete_dialog_label": "Hasło", + "account_delete_dialog_button_cancel": "Anuluj", + "account_upgrade_dialog_button_redirect_signup": "Załóż konto", + "account_upgrade_dialog_button_pay_now": "Zapłać i aktywuj subskrypcję", + "account_tokens_dialog_button_cancel": "Anuluj", + "account_tokens_dialog_expires_label": "Token dostępowy wygasa po", + "account_tokens_dialog_expires_unchanged": "Pozostaw termin ważności bez zmian", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezerwacja tematu", + "account_upgrade_dialog_tier_features_reservations_few": "{{reservations}} rezerwacje tematów", + "account_upgrade_dialog_tier_features_reservations_many": "{{reservations}} rezerwacji tematów", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} mail dziennie", + "account_upgrade_dialog_tier_features_emails_few": "{{emails}} maile dziennie", + "account_upgrade_dialog_tier_features_emails_many": "{{emails}} maili dziennie", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} wiadomość dziennie", + "account_upgrade_dialog_tier_features_messages_few": "{{messages}} wiadomości dziennie", + "account_upgrade_dialog_tier_features_messages_many": "{{messages}} wiadomości dziennie" +} diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json new file mode 100644 index 00000000..898d6eed --- /dev/null +++ b/web/public/static/langs/pt.json @@ -0,0 +1,230 @@ +{ + "action_bar_clear_notifications": "Limpar todas as notificações", + "action_bar_send_test_notification": "Enviar notificação de teste", + "action_bar_unsubscribe": "Anular subscrição", + "action_bar_toggle_mute": "Ativa/Desativa notificações", + "action_bar_toggle_action_menu": "Abrir/fechar menu de ação", + "message_bar_type_message": "Escreva uma mensagem aqui", + "message_bar_error_publishing": "Erro ao publicar notificação", + "message_bar_publish": "Publicar mensagem", + "nav_topics_title": "Tópicos subscritos", + "nav_button_all_notifications": "Todas notificações", + "nav_button_settings": "Configurações", + "nav_button_documentation": "Documentação", + "nav_button_publish_message": "Publicar notificação", + "nav_button_subscribe": "Subscrever tópico", + "nav_button_muted": "Notificações desativadas", + "nav_button_connecting": "A ligar", + "alert_notification_permission_required_title": "As notificações estão desativadas", + "alert_notification_permission_required_description": "Conceder permissão ao seu navegador para mostrar notificações.", + "alert_not_supported_title": "Notificações não suportadas", + "notifications_list": "Lista de notificações", + "alert_not_supported_description": "As notificações não são suportadas pelo seu navegador.", + "notifications_list_item": "Notificação", + "notifications_mark_read": "Marcar como lido", + "notifications_delete": "Apagar", + "notifications_copied_to_clipboard": "Copiado para a área de transferência", + "notifications_tags": "Etiquetas", + "notifications_priority_x": "Prioridade {{priority}}", + "notifications_new_indicator": "Nova notificação", + "notifications_attachment_image": "Imagem anexada", + "notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência", + "notifications_attachment_copy_url_button": "Copiar URL", + "notifications_attachment_open_title": "Ir para {{url}}", + "notifications_attachment_link_expired": "a ligação de descarga expirou", + "notifications_attachment_open_button": "Abrir anexo", + "notifications_attachment_link_expires": "a ligação expira em {{date}}", + "notifications_attachment_file_image": "ficheiro de imagem", + "notifications_attachment_file_video": "ficheiro de vídeo", + "notifications_attachment_file_audio": "ficheiro de áudio", + "notifications_attachment_file_app": "ficheiro apk Android", + "notifications_attachment_file_document": "outros documentos", + "notifications_click_copy_url_title": "Copiar URL da ligação para a área de transferência", + "notifications_click_copy_url_button": "Copiar ligação", + "notifications_click_open_button": "Abrir ligação", + "notifications_actions_open_url_title": "Ir para {{url}}", + "notifications_actions_not_supported": "Ação não suportada na app web", + "notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}", + "notifications_none_for_topic_title": "Ainda não recebeu nenhuma notificação deste tópico.", + "notifications_none_for_topic_description": "Para enviar notificações deste tópico, basta usar os métodos PUT ou POST no URL do tópico.", + "notifications_none_for_any_title": "Ainda não recebeu nenhuma notificação.", + "notifications_none_for_any_description": "Para enviar notificações dum tópico, basta usar os métodos PUT ou POST no URL do tópico. Eis um exemplo usando um dos seus tópicos.", + "notifications_no_subscriptions_title": "Parece que ainda não tem nenhuma inscrição.", + "notifications_no_subscriptions_description": "Clique na ligação \"{{linktext}}\" para criar ou subscrever um tópico. Depois, poderá enviar mensagens via PUT ou POST e receberá notificações aqui.", + "notifications_example": "Exemplo", + "notifications_more_details": "Para mais informações, aceda ao site ou à documentação.", + "notifications_loading": "A carregar notificações…", + "publish_dialog_title_topic": "Publicar em {{topic}}", + "publish_dialog_title_no_topic": "Publicar notificação", + "publish_dialog_progress_uploading": "A enviar …", + "publish_dialog_progress_uploading_detail": "A enviar {{loaded}}/{{total}} ({{percent}}%)…", + "publish_dialog_message_published": "Notificação publicada", + "publish_dialog_attachment_limits_file_and_quota_reached": "excede limite de ficheiro de {{fileSizeLimit}} e cota, {{remainingBytes}} restante(s)", + "publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restante(s)", + "publish_dialog_priority_min": "Prioridade mínima", + "publish_dialog_priority_low": "Prioridade baixa", + "publish_dialog_priority_default": "Prioridade padrão", + "publish_dialog_priority_high": "Prioridade alta", + "publish_dialog_base_url_label": "URL de serviço", + "publish_dialog_base_url_placeholder": "URL de serviço, por exemplo: https://exemplo.com", + "publish_dialog_topic_label": "Nome do tópico", + "publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"", + "publish_dialog_topic_reset": "Limpar tópico", + "publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"", + "publish_dialog_message_label": "Mensagem", + "publish_dialog_message_placeholder": "Escreva uma mensagem aqui", + "publish_dialog_tags_label": "Etiquetas", + "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup", + "publish_dialog_priority_label": "Prioridade", + "publish_dialog_click_label": "URL de clique", + "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", + "publish_dialog_click_reset": "Remover URL de clique", + "publish_dialog_email_label": "Email", + "publish_dialog_filename_placeholder": "Nome do ficheiro anexado", + "publish_dialog_email_placeholder": "Endereça para o qual encaminhar a notificação, por exemplo: filipe@exemplo.com", + "publish_dialog_email_reset": "Remover encaminhamento por email", + "publish_dialog_attach_label": "URL de anexo", + "publish_dialog_attach_placeholder": "Anexar ficheiro por URL, por exemplo: https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Remover URL de anexo", + "publish_dialog_filename_label": "Nome do ficheiro", + "publish_dialog_delay_label": "Atraso", + "publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo \"{{{unixTimestamp}}\", \"{{relativeTime}}\", ou \"{{naturalLanguage}}\" (apenas em Inglês)", + "publish_dialog_other_features": "Outras funcionalidades:", + "publish_dialog_chip_click_label": "URL de clique", + "publish_dialog_chip_topic_label": "Alterar tópico", + "publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todas as funcionalidades de envio, consulte a documentação.", + "publish_dialog_button_cancel_sending": "Cancelar o envio", + "publish_dialog_attached_file_filename_placeholder": "Nome do ficheiro anexado", + "publish_dialog_attached_file_remove": "Remover ficheiro anexado", + "emoji_picker_search_clear": "Limpar pesquisa", + "subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por palavra-passe, por isso escolha um nome que não seja fácil de adivinhar. Uma vez subscrito, pode usar os métodos PUT/POST para publicar notificações.", + "subscribe_dialog_subscribe_use_another_label": "Usar outro servidor", + "subscribe_dialog_error_user_not_authorized": "Utilizador {{username}} não autorizado", + "prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)", + "prefs_notifications_delete_after_one_week": "Após uma semana", + "prefs_notifications_delete_after_one_month": "Após um mês", + "prefs_notifications_delete_after_never_description": "As notificações nunca serão eliminadas automaticamente", + "prefs_notifications_delete_after_one_week_description": "As notificações serão eliminadas automaticamente após uma semana", + "prefs_notifications_delete_after_one_month_description": "As notificações serão eliminadas automaticamente após um mês", + "prefs_users_dialog_username_label": "Utilizador, por exemplo: \"filipe\"", + "prefs_users_dialog_password_label": "Palavra-passe", + "common_cancel": "Cancelar", + "common_add": "Adicionar", + "error_boundary_description": "Obviamente, isto não devia acontecer, lamentamos o sucedido.
Se tiver um minuto, por favor relate isto no GitHub, ou informe-nos através de Discord ou Matrix.", + "error_boundary_stack_trace": "Erro (\"stack trace\")", + "error_boundary_gathering_info": "A recolher mais informações …", + "error_boundary_unsupported_indexeddb_title": "Navegação anónima não suportada", + "error_boundary_unsupported_indexeddb_description": "A aplicação web ntfy necessita da \"IndexedDB\" para funcionar e o seu navegador não a suporta no modo de navegação privada.

Embora isso seja inconveniente, também não faz muito sentido usar a aplicação no modo de navegação privada de qualquer maneira, visto que tudo é guardado no armazenamento do navegador. Pode ler mais sobre isso nesta questão no GitHub, ou falar connosco por Discord ou Matrix.", + "action_bar_show_menu": "Mostrar menu", + "action_bar_logo_alt": "logótipo do ntfy", + "action_bar_settings": "Configurações", + "message_bar_show_dialog": "Mostrar caixa de publicação", + "alert_notification_permission_required_button": "Conceder agora", + "publish_dialog_attachment_limits_file_reached": "excede o limite de ficheiro de {{fileSizeLimit}}", + "publish_dialog_emoji_picker_show": "Escolher emoji", + "publish_dialog_priority_max": "Prioridade máxima", + "publish_dialog_title_label": "Título", + "publish_dialog_delay_reset": "Remover atraso de entrega", + "publish_dialog_chip_email_label": "Encaminhar para email", + "publish_dialog_chip_attach_url_label": "Anexar ficheiro por URL", + "publish_dialog_chip_attach_file_label": "Anexar ficheiro local", + "publish_dialog_chip_delay_label": "Atraso de entrega", + "publish_dialog_button_cancel": "Cancelar", + "publish_dialog_button_send": "Enviar", + "publish_dialog_checkbox_publish_another": "Publicar outra", + "publish_dialog_attached_file_title": "Ficheiro anexado:", + "publish_dialog_drop_file_here": "Arraste o ficheiro para aqui", + "emoji_picker_search_placeholder": "Pesquisar emoji", + "subscribe_dialog_subscribe_title": "Subscrever tópico", + "subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo: \"alertas_do_filipe\"", + "subscribe_dialog_subscribe_base_url_label": "URL de serviço", + "subscribe_dialog_subscribe_button_cancel": "Cancelar", + "subscribe_dialog_subscribe_button_subscribe": "Subscrever", + "subscribe_dialog_login_title": "Autenticação necessária", + "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.", + "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"", + "subscribe_dialog_login_password_label": "Palavra-passe", + "common_back": "Voltar", + "subscribe_dialog_login_button_login": "Autenticar", + "subscribe_dialog_error_user_anonymous": "anónimo", + "prefs_notifications_title": "Notificações", + "prefs_notifications_sound_title": "Som de notificações", + "prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam", + "prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam", + "prefs_notifications_sound_no_sound": "Sem som", + "prefs_notifications_sound_play": "Reproduzir som selecionado", + "prefs_notifications_min_priority_title": "Prioridade mínima", + "prefs_notifications_min_priority_description_any": "A mostrar todas as notificações, independentemente da prioridade", + "prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima", + "prefs_notifications_min_priority_any": "Qualquer prioridade", + "prefs_notifications_min_priority_low_and_higher": "Prioridade baixa e acima", + "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", + "prefs_notifications_min_priority_high_and_higher": "Prioridade alta e acima", + "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", + "prefs_notifications_delete_after_title": "Eliminar notificações", + "prefs_notifications_delete_after_never": "Nunca", + "prefs_notifications_delete_after_three_hours": "Após três horas", + "prefs_notifications_delete_after_one_day": "Após um dia", + "prefs_notifications_delete_after_three_hours_description": "As notificações serão eliminadas automaticamente após três horas", + "prefs_notifications_delete_after_one_day_description": "As notificações serão eliminadas automaticamente após um dia", + "prefs_users_title": "Gerir utilizadores", + "prefs_users_description": "Adicionar/remover utilizadores aos seus tópicos protegidos. Note que o utilizador e palavra-passe são guardados no armazenamento local do navegador.", + "prefs_users_table": "Tabela de utilizadores", + "prefs_users_add_button": "Adicionar utilizador", + "prefs_users_edit_button": "Editar utilizador", + "prefs_users_delete_button": "Apagar utilizador", + "prefs_users_table_user_header": "Utilizador", + "prefs_users_table_base_url_header": "URL de serviço", + "prefs_users_dialog_title_add": "Adicionar utilizador", + "prefs_users_dialog_title_edit": "Editar utilizador", + "prefs_users_dialog_base_url_label": "URL de serviço, por exemplo: https://ntfy.sh", + "common_save": "Gravar", + "prefs_appearance_title": "Aparência", + "prefs_appearance_language_title": "Idioma", + "priority_min": "mínima", + "priority_low": "baixa", + "priority_default": "padrão", + "priority_high": "alta", + "priority_max": "máxima", + "error_boundary_title": "Oh não, o ntfy parou de funcionar", + "error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")", + "signup_title": "Criar uma conta ntfy", + "signup_form_username": "Nome de utilizador", + "signup_form_confirm_password": "Confirmar palavra-passe", + "signup_form_button_submit": "Registar", + "signup_form_toggle_password_visibility": "Alternar visibilidade da palavra-passe", + "signup_already_have_account": "Já tem uma conta? Inicie sessão!", + "signup_disabled": "Novos registos desativados", + "signup_error_username_taken": "O nome \"{{username}}\" já está em uso", + "signup_error_creation_limit_reached": "Limite de criação de contas atingido", + "login_title": "Inicie sessão na sua conta ntfy", + "login_form_button_submit": "Iniciar sessão", + "login_disabled": "Início de sessão desativado", + "action_bar_account": "Conta", + "action_bar_change_display_name": "Alterar nome de exibição", + "action_bar_reservation_delete": "Remover reserva", + "action_bar_reservation_limit_reached": "Limite alcançado", + "action_bar_profile_title": "Perfil", + "action_bar_profile_settings": "Configurações", + "action_bar_profile_logout": "Terminar sessão", + "action_bar_sign_in": "Iniciar sessão", + "nav_upgrade_banner_description": "Reserve tópicos, envie mais mensagens, emails e anexos maiores", + "signup_form_password": "Palavra-passe", + "action_bar_reservation_edit": "Alterar reserva", + "login_link_signup": "Registar", + "action_bar_reservation_add": "Reservar tópico", + "action_bar_sign_up": "Registar", + "nav_button_account": "Conta", + "common_copy_to_clipboard": "Copiar", + "nav_upgrade_banner_label": "Atualizar para ntfy Pro", + "alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da API de Notificações.", + "display_name_dialog_title": "Alterar nome mostrado", + "display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.", + "display_name_dialog_placeholder": "Nome exibido", + "reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso", + "publish_dialog_call_label": "Chamada telefônica", + "publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'", + "publish_dialog_call_reset": "Remover chamada telefônica", + "publish_dialog_chip_call_label": "Chamada telefônica", + "subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome" +} diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 3efd57ce..79b2c14a 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -7,9 +7,9 @@ "nav_button_all_notifications": "Todas notificações", "nav_button_settings": "Configurações", "nav_button_subscribe": "Inscrever no tópico", - "alert_grant_title": "Notificações estão desativadas", - "alert_grant_description": "Conceder ao navegador permissão para mostrar notificações.", - "alert_grant_button": "Conceder agora", + "alert_notification_permission_required_title": "Notificações estão desativadas", + "alert_notification_permission_required_description": "Conceder ao navegador permissão para mostrar notificações.", + "alert_notification_permission_required_button": "Conceder agora", "alert_not_supported_title": "Notificações não são suportadas", "alert_not_supported_description": "Notificações não são suportadas pelo seu navagador.", "notifications_copied_to_clipboard": "Copiado para a área de transferência", @@ -34,5 +34,190 @@ "notifications_attachment_link_expires": "link expira em {{date}}", "notifications_attachment_copy_url_button": "Copiar URL", "notifications_attachment_link_expired": "link para transferência expirado", - "notifications_example": "Exemplo" + "notifications_example": "Exemplo", + "notifications_more_details": "Para mais informações, confira site ou documentação.", + "notifications_loading": "Carregando notificações…", + "subscribe_dialog_error_user_anonymous": "anônimo", + "prefs_notifications_delete_after_three_hours": "Após três horas", + "prefs_notifications_delete_after_one_day": "Após um dia", + "prefs_notifications_delete_after_one_week": "Após uma semana", + "prefs_notifications_delete_after_one_month": "Após um mês", + "notifications_actions_not_supported": "Ação não suportada no aplicativo web", + "notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}", + "notifications_actions_open_url_title": "Ir para {{url}}", + "publish_dialog_title_topic": "Publicar em {{topic}}", + "publish_dialog_title_no_topic": "Publicar notificação", + "publish_dialog_progress_uploading": "Enviando …", + "publish_dialog_progress_uploading_detail": "Fazendo upload de {{loaded}}/{{total}} ({{percent}}%)…", + "publish_dialog_message_published": "Notificação publicada", + "publish_dialog_attachment_limits_file_reached": "excede o limite de arquivo {{fileSizeLimit}}", + "publish_dialog_priority_min": "Prioridade mínima", + "publish_dialog_priority_low": "Baixa prioridade", + "publish_dialog_priority_default": "Prioridade padrão", + "publish_dialog_base_url_label": "URL de serviço", + "publish_dialog_base_url_placeholder": "URL de serviço, por exemplo https://example.com", + "publish_dialog_topic_label": "Nome do tópico", + "publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts", + "publish_dialog_title_label": "Título", + "publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco", + "publish_dialog_message_label": "Mensagem", + "publish_dialog_message_placeholder": "Digite uma mensagem aqui", + "publish_dialog_tags_label": "Etiquetas", + "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup", + "publish_dialog_priority_label": "Prioridade", + "publish_dialog_click_label": "Clique em URL", + "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Email para encaminhar a notificação, por exemplo phil@example.com", + "publish_dialog_filename_label": "Nome do arquivo", + "publish_dialog_filename_placeholder": "Nome do arquivo anexado", + "publish_dialog_delay_label": "Atraso", + "publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo {{{unixTimestamp}}, {{relativeTime}}, ou \"{{naturalLanguage}}\" (apenas em inglês)", + "publish_dialog_other_features": "Outros recursos:", + "publish_dialog_chip_click_label": "Clique em URL", + "publish_dialog_chip_attach_file_label": "Anexar arquivo local", + "publish_dialog_chip_delay_label": "Atraso na entrega", + "publish_dialog_chip_topic_label": "Alterar tópico", + "publish_dialog_button_cancel_sending": "Cancelar o envio", + "publish_dialog_attached_file_filename_placeholder": "Nome do arquivo anexado", + "publish_dialog_drop_file_here": "Solte o arquivo aqui", + "emoji_picker_search_placeholder": "Pesquisar emoji", + "subscribe_dialog_subscribe_title": "Inscrever no tópico", + "subscribe_dialog_subscribe_use_another_label": "Usar outro servidor", + "subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por senha, então escolha um nome que não seja fácil de adivinhar. Uma vez inscrito, você pode PUT/POST notificações.", + "subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo phil_alerts", + "subscribe_dialog_subscribe_button_cancel": "Cancelar", + "subscribe_dialog_subscribe_button_subscribe": "Inscrever", + "prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)", + "prefs_notifications_min_priority_any": "Qualquer prioridade", + "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", + "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", + "subscribe_dialog_login_password_label": "Senha", + "common_back": "Voltar", + "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima", + "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", + "prefs_notifications_delete_after_title": "Apagar notificações", + "prefs_notifications_delete_after_never": "Nunca", + "prefs_notifications_delete_after_never_description": "Notificações nunca serão auto excluídas", + "prefs_users_description": "Adicionar/remover usuários em seus tópicos protegidos. Note que o usuário e senha são salvos no armazenamento local do navegador.", + "prefs_users_add_button": "Adicionar usuário", + "prefs_users_table_user_header": "Usuário", + "prefs_users_table_base_url_header": "URL de serviço", + "prefs_users_dialog_title_add": "Adicionar usuário", + "prefs_users_dialog_title_edit": "Editar usuário", + "prefs_users_dialog_base_url_label": "URL de serviço, exemplo https://ntfy.sh", + "prefs_users_dialog_username_label": "Usuário, por exemplo phil", + "prefs_users_dialog_password_label": "Senha", + "common_cancel": "Cancelar", + "common_add": "Adicionar", + "common_save": "Salvar", + "prefs_appearance_title": "Aparência", + "prefs_appearance_language_title": "LInguagem", + "priority_min": "minima", + "priority_low": "baixa", + "priority_default": "padrão", + "priority_high": "alta", + "priority_max": "máxima", + "error_boundary_title": "Ah não, ntfy parou de funcionar", + "error_boundary_gathering_info": "Coletar mais informações …", + "error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.
Se tiver um minuto, por favor relate isto no GitHub, ou informe-nos através de Discord ou Matrix.", + "error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha", + "error_boundary_stack_trace": "Rastreamento de pilha", + "publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante", + "publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restantes", + "publish_dialog_priority_high": "Alta prioridade", + "publish_dialog_priority_max": "Prioridade máxima", + "publish_dialog_button_send": "Enviar", + "publish_dialog_attached_file_title": "Arquivo anexado:", + "publish_dialog_attach_label": "URL de anexo", + "publish_dialog_chip_attach_url_label": "Anexar arquivo por URL", + "publish_dialog_attach_placeholder": "Anexar arquivo por URL, por exemplo, https://f-droid.org/F-Droid.apk", + "publish_dialog_chip_email_label": "Encaminhar para email", + "publish_dialog_checkbox_publish_another": "Publicar outro", + "publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todos os recursos de envio, consulte a documentação.", + "publish_dialog_button_cancel": "Cancelar", + "prefs_notifications_delete_after_one_day_description": "Notificações são automaticamente excluídas após um dia", + "prefs_notifications_delete_after_one_month_description": "Notificações são automaticamente excluídas após um mês", + "prefs_users_title": "Gerenciar usuários", + "subscribe_dialog_error_user_not_authorized": "Usuário {{username}} não autorizado", + "prefs_notifications_title": "Notificações", + "prefs_notifications_sound_no_sound": "Sem som", + "subscribe_dialog_login_title": "Login necessário", + "prefs_notifications_sound_title": "Som de notificações", + "prefs_notifications_min_priority_title": "Mínima prioridade", + "prefs_notifications_min_priority_description_any": "Mostrando todas as notificações, independente da prioridade", + "prefs_notifications_delete_after_one_week_description": "Notificações são automaticamente excluídas após uma semana", + "subscribe_dialog_login_description": "Esse tópico é protegido por senha. Por favor digite o nome de usuário e senha para inscrever.", + "subscribe_dialog_login_username_label": "Nome, por exemplo phil", + "subscribe_dialog_login_button_login": "Login", + "prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam", + "prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam", + "prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima", + "prefs_notifications_delete_after_three_hours_description": "Notificações são automaticamente excluídas após três horas", + "publish_dialog_attach_reset": "Remover URL do anexo", + "publish_dialog_emoji_picker_show": "Escolher emoji", + "publish_dialog_attached_file_remove": "Remover arquivo anexado", + "emoji_picker_search_clear": "Limpar", + "subscribe_dialog_subscribe_base_url_label": "URL de subscrição", + "notifications_list": "Lista de notificações", + "message_bar_show_dialog": "Mostrar caixa de publicação", + "publish_dialog_topic_reset": "Resetar tópico", + "publish_dialog_delay_reset": "Remover entrega adiada da notificação", + "nav_button_connecting": "Conectando", + "publish_dialog_email_reset": "Remover encaminhar email", + "prefs_notifications_sound_play": "Reproduzir som selecionado", + "action_bar_show_menu": "Mostrar menu", + "action_bar_toggle_mute": "Habilita/Desabilita notificações", + "action_bar_toggle_action_menu": "Abrir/fechar menu de ação", + "action_bar_logo_alt": "nfty logo", + "message_bar_publish": "Publicar mensagem", + "nav_button_muted": "Notificações desabilitadas", + "notifications_list_item": "Notificação", + "notifications_mark_read": "Marcar como lido", + "notifications_delete": "Excluir", + "notifications_priority_x": "Prioridade {{priority}}", + "notifications_new_indicator": "Nova notificação", + "notifications_attachment_image": "Imagem anexada", + "notifications_attachment_file_image": "Arquivo de imagem", + "notifications_attachment_file_video": "Arquivo de vídeo", + "notifications_attachment_file_audio": "Arquivo de áudio", + "notifications_attachment_file_app": "Arquivo apk android", + "notifications_attachment_file_document": "Outros documentos", + "publish_dialog_click_reset": "Remover URL clicável", + "prefs_users_table": "Tabela de usuários", + "prefs_users_edit_button": "Editar usuário", + "prefs_users_delete_button": "Excluir usuário", + "error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada", + "error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.

Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso nesta edição do GitHub, ou falar conosco em Discord ou Matrix.", + "action_bar_reservation_add": "Reserve topic", + "action_bar_reservation_edit": "Change reservation", + "signup_disabled": "Registrar está desativado", + "signup_error_username_taken": "Usuário {{username}} já existe", + "signup_error_creation_limit_reached": "Limite de criação de contas atingido", + "action_bar_reservation_delete": "Remover reserva", + "action_bar_account": "Conta", + "action_bar_change_display_name": "Change display name", + "common_copy_to_clipboard": "Copiar para área de transferência", + "login_link_signup": "Registrar", + "login_title": "Entrar na sua conta ntfy", + "login_form_button_submit": "Entrar", + "login_disabled": "Login está desabilitado", + "action_bar_reservation_limit_reached": "Limite atingido", + "action_bar_profile_title": "Perfil", + "action_bar_profile_settings": "Configurações", + "action_bar_profile_logout": "Sair", + "action_bar_sign_in": "Entrar", + "action_bar_sign_up": "Registrar", + "nav_button_account": "Conta", + "signup_title": "Criar uma conta ntfy", + "signup_form_username": "Usuário", + "signup_form_password": "Senha", + "signup_form_confirm_password": "Confirmar senha", + "signup_form_button_submit": "Registrar", + "account_basics_phone_numbers_title": "Telefones", + "signup_form_toggle_password_visibility": "Ativar visibilidade de senha", + "signup_already_have_account": "Já possui uma conta? Entrar!", + "nav_upgrade_banner_label": "Atualizar para ntfy Pro", + "account_basics_phone_numbers_dialog_description": "Para usar o recurso de notificação de chamada, é necessários adicionar e verificar pelo menos um número de telefone. A verificação pode ser feita por SMS ou chamada telefônica.", + "account_basics_phone_numbers_description": "Para notificações de chamada telefônica" } diff --git a/web/public/static/langs/ro.json b/web/public/static/langs/ro.json new file mode 100644 index 00000000..bfb90b50 --- /dev/null +++ b/web/public/static/langs/ro.json @@ -0,0 +1,105 @@ +{ + "action_bar_show_menu": "Afișează meniu", + "action_bar_send_test_notification": "Trimite notificare de probă", + "action_bar_clear_notifications": "Șterge toate notificările", + "action_bar_settings": "Setări", + "action_bar_unsubscribe": "Dezabonare", + "action_bar_logo_alt": "logo-ul ntfy", + "action_bar_toggle_mute": "Oprire/activare notificări", + "message_bar_type_message": "Scrie un mesaj aici", + "message_bar_error_publishing": "Eroare la publicarea notificării", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Setări", + "nav_button_settings": "Setări", + "nav_button_connecting": "conectare", + "notifications_attachment_file_video": "fișier video", + "publish_dialog_priority_default": "Prioritate default", + "publish_dialog_priority_high": "Prioritate înaltă", + "publish_dialog_priority_max": "Max. prioritate", + "publish_dialog_message_placeholder": "Introdu un mesaj aici", + "nav_button_subscribe": "Abonează-te la topic", + "nav_upgrade_banner_label": "Upgrade la ntfy Pro", + "nav_upgrade_banner_description": "Rezervă topic-uri, mai multe mesaje și email-uri, și atașamente mai mari", + "common_back": "Înapoi", + "nav_button_account": "Cont", + "nav_button_documentation": "Documentație", + "nav_button_publish_message": "Publică notificarea", + "alert_notification_permission_required_title": "Notificările sunt dezactivate", + "alert_notification_permission_required_button": "Permite acum", + "alert_not_supported_title": "Notificările nu sunt acceptate", + "alert_not_supported_description": "Notificările nu sunt acceptate în browser.", + "alert_notification_permission_required_description": "Permite browser-ului să afișeze notificări.", + "notifications_list": "Lista de notificări", + "notifications_list_item": "Notificare", + "notifications_mark_read": "Marchează ca citit", + "notifications_delete": "Șterge", + "notifications_copied_to_clipboard": "Copiat în clipboard", + "notifications_tags": "Tag-uri", + "notifications_new_indicator": "Notificare nouă", + "notifications_attachment_image": "Imagine atașament", + "notifications_attachment_copy_url_title": "Copiază URL-ul atașamentului în clipboard", + "notifications_attachment_copy_url_button": "Copiază URL", + "notifications_attachment_open_title": "Mergi la {{url}}", + "notifications_attachment_link_expires": "link-ul expiră {{date}}", + "notifications_actions_not_supported": "Acțiune neacceptată în aplicația web", + "notifications_actions_http_request_title": "Trimite {{method}} HTTP la {{url}}", + "notifications_none_for_topic_title": "N-ați primit încă notificări pe acest subiect.", + "notifications_none_for_topic_description": "Pentru a trimite notificări pe acest subiect, setați PUT sau POST pe URL-ul subiectului.", + "notifications_none_for_any_title": "N-ați primit nici o notificare.", + "notifications_none_for_any_description": "Pentru a trimite notificări pe acest subiect, setează PUT sau POST pe URL-ul subiectului. Uite un exemplu cu unul dintre subiectele tale.", + "notifications_no_subscriptions_title": "Se pare că nu ai nici o înscriere.", + "notifications_no_subscriptions_description": "Click pe link-ul \"{{linktext}}\" ca sa creezi o înscriere la un subiect. După aceea, poți trimite mesaje via PUT sau POST și vei primi notificări aici.", + "notifications_example": "Exemplu", + "notifications_more_details": "Pentru mai multe informații, vezi site-ul web sau documentația.", + "display_name_dialog_title": "Schimbă numele afișat", + "display_name_dialog_description": "Setează un nume alternativ pentru subiect care este afișat în lista de înscrieri. Va ajuta la ușurarea identificării subiectelor cu nume complexe.", + "display_name_dialog_placeholder": "Nume afișat", + "reserve_dialog_checkbox_label": "Rezervă subiectul și configurează accesul", + "publish_dialog_progress_uploading": "Încărcare…", + "publish_dialog_progress_uploading_detail": "Încărcare {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Notificare publicată", + "publish_dialog_attachment_limits_file_and_quota_reached": "depășește {{fileSizeLimit}} limita fișierului și cota, {{remainingBytes}} mai rămân", + "publish_dialog_attachment_limits_file_reached": "depășește {{fileSizeLimit}} limita fișierului", + "publish_dialog_attachment_limits_quota_reached": "depășește cota, {{remainingBytes}} mai rămân", + "publish_dialog_priority_min": "Min. prioritate", + "publish_dialog_base_url_label": "URL serviciu", + "publish_dialog_base_url_placeholder": "URL serviciu, ex: https://example.com", + "publish_dialog_topic_label": "Nume subiect", + "publish_dialog_topic_placeholder": "Nume subiect, ex: alerte_phil", + "publish_dialog_topic_reset": "Resetare subiect", + "publish_dialog_title_label": "Titlu", + "publish_dialog_title_placeholder": "Titlu notificare, ex: Alerta spațiu disc", + "publish_dialog_message_label": "Mesaj", + "publish_dialog_tags_label": "Tag-uri", + "publish_dialog_tags_placeholder": "Lista de tag-uri separate prin virgula, ex: avertizare,srv1-backup", + "publish_dialog_priority_label": "Prioritate", + "publish_dialog_click_label": "Click URL", + "publish_dialog_click_placeholder": "URL deschis când notificarea este selectată", + "publish_dialog_click_reset": "Șterge URL selecție", + "publish_dialog_email_label": "E-mail", + "signup_form_confirm_password": "Confirmă parola", + "action_bar_account": "Cont", + "action_bar_change_display_name": "Schimbă numele afișat", + "action_bar_reservation_limit_reached": "Limita atinsă", + "common_cancel": "Anulează", + "common_save": "Salvează", + "common_add": "Adaugă", + "signup_form_password": "Parolă", + "publish_dialog_title_topic": "Publică în {{topic}}", + "publish_dialog_title_no_topic": "Publică notificare", + "nav_button_all_notifications": "Toate notificările", + "notifications_priority_x": "Prioritate {{priority}}", + "notifications_attachment_file_image": "fișier imagine", + "notifications_attachment_open_button": "Deschide atașament", + "notifications_attachment_file_audio": "fișier audio", + "notifications_actions_open_url_title": "Mergi la {{url}}", + "notifications_attachment_file_document": "alt document", + "notifications_attachment_link_expired": "link-ul de descărcare expirat", + "notifications_attachment_file_app": "fișier aplicație Android", + "notifications_click_copy_url_title": "Copiază URL-ul în clipboard", + "notifications_click_copy_url_button": "Copiază link", + "notifications_click_open_button": "Deschide link", + "publish_dialog_emoji_picker_show": "Alege un emoji", + "notifications_loading": "Încărcare notificări…", + "publish_dialog_priority_low": "Prioritate joasă" +} diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index ed97047a..71cef5a4 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -1,30 +1,30 @@ { - "publish_dialog_priority_min": "Мин. приоритет", + "publish_dialog_priority_min": "Минимальный приоритет", "action_bar_settings": "Настройки", "action_bar_send_test_notification": "Отправить тестовое уведомление", "action_bar_clear_notifications": "Удалить все уведомления", "action_bar_unsubscribe": "Отписаться", "message_bar_type_message": "Введите сообщение здесь", - "notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто отправьте PUT или POST на URL-адрес этой темы.", - "notifications_none_for_any_description": "Чтобы отправить уведомления на тему, просто отправьте PUT или POST на URL-адрес темы. Вот пример используя одну из ваших тем.", - "notifications_no_subscriptions_title": "Похоже у вас ещё нет подписок.", - "alert_grant_description": "Разрешите браузеру показывать уведомления.", - "notifications_no_subscriptions_description": "Нажмите \"{{linktext}}\" ссылку, чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST, и вы будете получать здесь уведомления.", + "notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто сделаете PUT или POST-запрос на URL-адрес этой темы.", + "notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.", + "notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.", + "alert_notification_permission_required_description": "Разрешите браузеру показывать уведомления.", + "notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.", "notifications_example": "Пример", - "notifications_more_details": "Дополнительную информацию найдёте на сайте или в документации.", - "notifications_loading": "Загружаются уведомления …", + "notifications_more_details": "Для более подробной информации, посетите наш сайт или документацию.", + "notifications_loading": "Идет загрузка уведомлений …", "publish_dialog_title_topic": "Опубликовать в {{topic}}", "publish_dialog_title_no_topic": "Опубликовать уведомление", - "publish_dialog_progress_uploading": "Загружается …", + "publish_dialog_progress_uploading": "Идет загрузка …", "publish_dialog_progress_uploading_detail": "Загружается {{loaded}}/{{total}} ({{percent}}%) …", "publish_dialog_message_published": "Уведомление опубликовано", - "publish_dialog_attachment_limits_file_and_quota_reached": "превышает {{fileSizeLimit}} размер файла, {{remainingBytes}} осталось", - "publish_dialog_attachment_limits_file_reached": "превышает {{fileSizeLimit}} размер файла", - "publish_dialog_attachment_limits_quota_reached": "превышает квоту, {{remainingBytes}} осталось", + "publish_dialog_attachment_limits_file_and_quota_reached": "превышает максимальный размер файла {{fileSizeLimit}} и квоту, осталось {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "превышает максимальный размер файла {{fileSizeLimit}}", + "publish_dialog_attachment_limits_quota_reached": "превышает квоту, осталось {{remainingBytes}}", "publish_dialog_priority_low": "Низкий приоритет", - "publish_dialog_priority_default": "Приоритет по умолчанию", + "publish_dialog_priority_default": "Стандартный приоритет", "publish_dialog_priority_high": "Высокий приоритет", - "publish_dialog_priority_max": "Макс. приоритет", + "publish_dialog_priority_max": "Максимальный приоритет", "publish_dialog_base_url_label": "URL-адрес сервиса", "publish_dialog_base_url_placeholder": "URL-адрес сервиса, например https://example.com", "publish_dialog_topic_label": "Название темы", @@ -32,14 +32,14 @@ "publish_dialog_title_label": "Заголовок", "publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert", "publish_dialog_message_label": "Сообщение", - "publish_dialog_message_placeholder": "Текст сообщения", + "publish_dialog_message_placeholder": "Введите сообщение здесь", "publish_dialog_tags_label": "Тэги", - "publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например warning, srv1-backup", + "publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например: warning, srv1-backup", "publish_dialog_priority_label": "Приоритет", - "publish_dialog_click_label": "Нажмите на URL-адрес", - "publish_dialog_click_placeholder": "URL-адрес который откроется когда будет нажато уведомление", - "publish_dialog_email_label": "Эл. почта", - "message_bar_error_publishing": "Ошибка отправки уведомления", + "publish_dialog_click_label": "Ссылка при открытии", + "publish_dialog_click_placeholder": "URL-адрес, который откроется при нажатии на уведомление", + "publish_dialog_email_label": "Электронная почта", + "message_bar_error_publishing": "Ошибка публикации уведомления", "alert_not_supported_title": "Уведомления не поддерживаются", "alert_not_supported_description": "Уведомления не поддерживаются вашим браузером.", "notifications_copied_to_clipboard": "Скопировано в буфер обмена", @@ -51,13 +51,13 @@ "nav_button_documentation": "Документация", "nav_button_publish_message": "Опубликовать уведомление", "nav_button_subscribe": "Подписаться на тему", - "alert_grant_button": "Разрешить", + "alert_notification_permission_required_button": "Разрешить", "notifications_attachment_copy_url_button": "Скопировать URL-адрес", "notifications_attachment_open_title": "Перейти на {{url}}", "notifications_attachment_link_expired": "срок действия ссылки для скачивания истёк", "notifications_click_copy_url_button": "Скопировать ссылку", "notifications_none_for_any_title": "Вы ещё не получали никаких уведомлений.", - "alert_grant_title": "Уведомления отключены", + "alert_notification_permission_required_title": "Уведомления отключены", "notifications_attachment_copy_url_title": "Скопировать URL-адрес вложения", "notifications_actions_open_url_title": "Перейти на {{url}}", "notifications_tags": "Тэги", @@ -66,30 +66,30 @@ "notifications_click_open_button": "Открыть ссылку", "subscribe_dialog_subscribe_title": "Подписаться на тему", "publish_dialog_button_cancel": "Отмена", - "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы можете размещать/отправлять уведомления.", + "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.", "prefs_users_description": "Добавляйте/удаляйте пользователей для защищенных тем. Обратите внимание, что имя пользователя и пароль хранятся в локальном хранилище браузера.", - "error_boundary_description": "Этого, очевидно, не должно происходить. Очень сожалею об этом.
Если у вас есть минутка, пожалуйста сообщить об этом на GitHub, или сообщите нам через Discord или Matrix.", + "error_boundary_description": "Это не должно было случиться. Нам очень жаль.
Если Вы можете уделить минуту своего времени, пожалуйста сообщите об этом на GitHub, или дайте нам знать через Discord или Matrix.", "publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com", "publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk", "publish_dialog_filename_label": "Имя файла", "publish_dialog_delay_label": "Задержка", - "publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)", - "publish_dialog_chip_click_label": "Адрес", + "publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, или \"{{naturalLanguage}}\" (только по-английски)", + "publish_dialog_chip_click_label": "URL-адрес при нажатии", "publish_dialog_chip_email_label": "Переслать на электронную почту", "publish_dialog_chip_attach_url_label": "Прикрепить файл по URL", "publish_dialog_chip_attach_file_label": "Прикрепить локальный файл", - "publish_dialog_chip_delay_label": "Задержка отправки", + "publish_dialog_chip_delay_label": "Задержать доставку", "publish_dialog_chip_topic_label": "Изменить тему", - "publish_dialog_details_examples_description": "Примеры и подробное описание всех функций см. в e документации.", + "publish_dialog_details_examples_description": "Примеры и подробное описание всех функций смотрите в документации.", "publish_dialog_attach_label": "URL-адрес вложения", "publish_dialog_filename_placeholder": "Имя файла вложения", "publish_dialog_other_features": "Другие возможности:", "publish_dialog_button_cancel_sending": "Отменить отправку", "publish_dialog_button_send": "Отправить", "publish_dialog_checkbox_publish_another": "Опубликовать еще", - "publish_dialog_attached_file_title": "Прикрепленный файл:", + "publish_dialog_attached_file_title": "Прикреплённый файл:", "publish_dialog_attached_file_filename_placeholder": "Имя прикреплённого файла", - "emoji_picker_search_placeholder": "Поиск эмодзи", + "emoji_picker_search_placeholder": "Поиск смайликов", "subscribe_dialog_subscribe_topic_placeholder": "Название темы. Например, phil_alerts", "subscribe_dialog_subscribe_use_another_label": "Использовать другой сервер", "subscribe_dialog_subscribe_button_cancel": "Отмена", @@ -98,26 +98,26 @@ "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", - "subscribe_dialog_error_user_anonymous": "аноним", + "subscribe_dialog_error_user_anonymous": "анонимный пользователь", "prefs_notifications_title": "Уведомления", "prefs_notifications_sound_title": "Звук уведомления", "prefs_notifications_sound_description_none": "Уведомления не воспроизводят никаких звуков при получении", "prefs_notifications_sound_no_sound": "Без звука", "prefs_notifications_min_priority_title": "Минимальный приоритет", - "prefs_notifications_min_priority_description_any": "Показать все уведомления, независимо от приоритета", + "prefs_notifications_min_priority_description_any": "Показывать все уведомления, независимо от приоритета", "prefs_notifications_min_priority_description_x_or_higher": "Показывать уведомления, если приоритет {{number}} ({{name}}) или выше", - "prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимум)", + "prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимальный)", "prefs_notifications_min_priority_any": "Любой приоритет", - "prefs_notifications_min_priority_low_and_higher": "Низкий и высокий приоритет", + "prefs_notifications_min_priority_low_and_higher": "Низкий приоритет и выше", "prefs_notifications_min_priority_max_only": "Только максимальный приоритет", "prefs_notifications_delete_after_title": "Удалить уведомления", "prefs_notifications_delete_after_never": "Никогда", "prefs_notifications_delete_after_three_hours": "Через три часа", "prefs_notifications_sound_description_some": "Уведомления воспроизводят звук {{sound}}", - "prefs_notifications_min_priority_default_and_higher": "Приоритет по умолчанию и высокий", + "prefs_notifications_min_priority_default_and_higher": "Стандартный приоритет и выше", "prefs_notifications_delete_after_one_day": "Через день", "prefs_notifications_delete_after_one_week": "Через неделю", "prefs_notifications_delete_after_one_month": "Через месяц", @@ -129,26 +129,256 @@ "prefs_users_title": "Управление пользователями", "prefs_users_add_button": "Добавить пользователя", "prefs_users_table_user_header": "Пользователь", - "prefs_users_table_base_url_header": "URL службы", + "prefs_users_table_base_url_header": "URL сервера", "prefs_users_dialog_title_add": "Добавить пользователя", "prefs_users_dialog_title_edit": "Редактировать пользователя", - "prefs_users_dialog_base_url_label": "URL-адрес службы. Например, https://ntfy.sh", + "prefs_users_dialog_base_url_label": "URL-адрес сервера. Например, https://ntfy.sh", "prefs_users_dialog_username_label": "Имя пользователя. Например, phil", "prefs_users_dialog_password_label": "Пароль", - "prefs_users_dialog_button_cancel": "Отмена", - "prefs_users_dialog_button_add": "Добавить", - "prefs_users_dialog_button_save": "Сохранить", + "common_cancel": "Отмена", + "common_add": "Добавить", + "common_save": "Сохранить", "prefs_appearance_title": "Внешний вид", "prefs_appearance_language_title": "Язык", - "priority_min": "минимум", + "priority_min": "минимальный", "priority_low": "низкий", - "priority_default": "по умолчанию", + "priority_default": "стандартный", "priority_high": "высокий", "priority_max": "максимальный", - "error_boundary_title": "О нет, Ntfy сломался", - "error_boundary_button_copy_stack_trace": "Копирование трассировки стека", + "error_boundary_title": "О нет, ntfy сломался", + "error_boundary_button_copy_stack_trace": "Скопировать трассировку стека", "error_boundary_stack_trace": "Трассировка стека", - "error_boundary_gathering_info": "Соберите больше информации …", - "publish_dialog_drop_file_here": "Перетащите файл юда", - "prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше" + "error_boundary_gathering_info": "Идет сбор дополнительной информации …", + "publish_dialog_drop_file_here": "Перетащите файл сюда", + "prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше", + "action_bar_toggle_action_menu": "Открыть/закрыть меню", + "action_bar_show_menu": "Показать меню", + "action_bar_logo_alt": "Логотип ntfy", + "emoji_picker_search_clear": "Сбросить поиск", + "account_upgrade_dialog_cancel_warning": "Это действие отменит Вашу подписку и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше будут удалены.", + "account_tokens_table_create_token_button": "Создать токен доступа", + "account_tokens_table_last_origin_tooltip": "с IP-адреса {{ip}}, нажмите для подробностей", + "account_tokens_dialog_title_edit": "Изменить токен доступа", + "account_delete_dialog_button_cancel": "Отмена", + "account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.", + "account_delete_dialog_description": "Это действие безвозвратно удалит Вашу учетную запись, включая все Ваши данные хранящиеся на сервере. После удаления, Ваше имя пользователя не будет доступно для регистрации в течении 7 дней. Если Вы действительно хотите продолжить, пожалуйста введите Ваш пароль ниже.", + "account_delete_dialog_label": "Пароль", + "reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.", + "prefs_reservations_table": "Список зарезервированных тем", + "prefs_reservations_table_access_header": "Доступ", + "prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать", + "prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", + "reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения", + "reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.", + "prefs_reservations_table_not_subscribed": "Не подписан", + "prefs_reservations_table_everyone_deny_all": "Только я могу публиковать и подписываться", + "prefs_reservations_table_everyone_read_write": "Все могут публиковать и подписываться", + "prefs_reservations_table_click_to_subscribe": "Нажмите чтобы подписаться", + "prefs_reservations_dialog_title_add": "Зарезервировать тему", + "prefs_reservations_dialog_title_delete": "Удалить резервирование", + "prefs_reservations_dialog_title_edit": "Изменение резервированной темы", + "prefs_reservations_table_topic_header": "Тема", + "prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.", + "prefs_users_delete_button": "Удалить пользователя", + "prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя", + "account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы одну зарезервированную тему. Вы можете это сделать в Настройках.", + "account_upgrade_dialog_proration_info": "Пересчёт оплаты: при расширении подписки, разница в цене от текущей спишется сразу. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", + "account_tokens_table_never_expires": "Никогда", + "account_tokens_table_copied_to_clipboard": "Токен доступа скопирован", + "account_tokens_table_cannot_delete_or_edit": "Невозможно изменить или удалить токен текущего сеанса", + "account_tokens_delete_dialog_description": "Перед удалением токена доступа, убедитесь что он не используется приложениями и скриптами. Это действие невозможно отменить.", + "error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается", + "account_tokens_dialog_button_create": "Создать токен", + "account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен", + "account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы {{count}} зарезервированных тем. Вы можете это сделать в Настройках.", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем", + "account_upgrade_dialog_tier_selected_label": "Выбранная", + "account_tokens_table_current_session": "Текущий сеанс браузера", + "account_tokens_dialog_button_update": "Изменить токен", + "account_tokens_dialog_expires_label": "Токен доступа истекает", + "account_tokens_dialog_expires_x_hours": "Токен истекает через {{hours}} часов", + "account_tokens_dialog_expires_never": "Токен никогда не истекает", + "prefs_notifications_sound_play": "Воспроизводить выбранный звук", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервированных тем", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} эл. сообщений в день", + "account_basics_tier_free": "Бесплатный", + "account_tokens_dialog_title_create": "Создать токен доступа", + "account_tokens_dialog_title_delete": "Удалить токен доступа", + "common_copy_to_clipboard": "Скопировать в буфер обмена", + "account_tokens_dialog_button_cancel": "Отмена", + "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", + "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", + "account_tokens_delete_dialog_title": "Удалить токен доступа", + "prefs_users_table": "Список пользоваетелй", + "account_upgrade_dialog_tier_current_label": "Текущая", + "account_upgrade_dialog_button_cancel": "Отмена", + "prefs_users_edit_button": "Редактировать пользователя", + "account_basics_tier_upgrade_button": "Подписаться на Pro", + "account_basics_tier_paid_until": "Подписка оплачена до {{date}} и будет продляться автоматически", + "account_basics_tier_change_button": "Изменить", + "account_delete_dialog_button_submit": "Безвозвратно удалить учетную запись", + "account_upgrade_dialog_title": "Изменить уровень учетной записи", + "account_usage_basis_ip_description": "Статистика и ограничения на использование учитываются по IP-адресу, поэтому они могут совмещаться с другими пользователями. Уровни, указанные выше, примерно соответствуют текущим ограничениям.", + "publish_dialog_topic_reset": "Сбросить тему", + "account_basics_tier_admin_suffix_no_tier": "(без подписки)", + "prefs_reservations_dialog_topic_label": "Тема", + "signup_form_username": "Имя пользователя", + "signup_form_password": "Пароль", + "signup_form_confirm_password": "Подтвердите пароль", + "signup_form_button_submit": "Зарегистрироваться", + "signup_form_toggle_password_visibility": "Показать/скрыть пароль", + "signup_disabled": "Регистрация недоступна", + "signup_error_username_taken": "Имя пользователя {{username}} уже занято", + "signup_title": "Создать учетную запись ntfy", + "signup_already_have_account": "Уже есть учетная запись? Войдите!", + "signup_error_creation_limit_reached": "Лимит на создание учетных записей исчерпан", + "login_form_button_submit": "Вход", + "login_link_signup": "Регистрация", + "login_disabled": "Вход недоступен", + "action_bar_reservation_add": "Зарезервировать тему", + "action_bar_reservation_edit": "Изменить резервирование", + "action_bar_reservation_delete": "Удалить резервирование", + "action_bar_profile_title": "Профиль", + "action_bar_profile_settings": "Настройки", + "action_bar_profile_logout": "Выход", + "action_bar_sign_in": "Вход", + "action_bar_sign_up": "Регистрация", + "action_bar_change_display_name": "Изменить псевдоним", + "message_bar_publish": "Опубликовать сообщение", + "nav_button_muted": "Уведомления заглушены", + "nav_button_connecting": "установка соединения", + "action_bar_account": "Учетная запись", + "login_title": "Вход в Вашу учетную запись ntfy", + "action_bar_reservation_limit_reached": "Лимит исчерпан", + "action_bar_toggle_mute": "Заглушить/разрешить уведомления", + "nav_button_account": "Учетная запись", + "nav_upgrade_banner_label": "Подпишитесь на ntfy Pro", + "message_bar_show_dialog": "Открыть диалог публикации", + "notifications_list": "Список уведомлений", + "notifications_list_item": "Уведомление", + "notifications_mark_read": "Пометить как прочтенное", + "notifications_priority_x": "Приоритет {{priority}}", + "notifications_attachment_image": "Приложенное изображение", + "notifications_attachment_file_audio": "звуковой файл", + "notifications_attachment_file_video": "видео файл", + "notifications_attachment_file_image": "графический файл", + "notifications_attachment_file_app": "исполняемый файл Android", + "notifications_attachment_file_document": "другой тип файла", + "notifications_actions_not_supported": "Действие не поддерживается в веб-приложении", + "display_name_dialog_title": "Изменить псевдоним", + "display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.", + "reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ", + "publish_dialog_emoji_picker_show": "Выбрать смайлик", + "publish_dialog_click_reset": "Удалить ссылку", + "publish_dialog_email_reset": "Удалить адрес для пересылки", + "publish_dialog_attach_reset": "Удалить URL-адрес вложения", + "publish_dialog_delay_reset": "Удалить задержку доставки", + "publish_dialog_attached_file_remove": "Удалить прикреплённый файл", + "subscribe_dialog_subscribe_base_url_label": "URL-адрес сервера", + "subscribe_dialog_subscribe_button_generate_topic_name": "Сгенерировать случайное имя", + "subscribe_dialog_error_topic_already_reserved": "Тема уже зарезервирована", + "account_basics_title": "Учетная запись", + "account_basics_username_title": "Имя пользователя", + "account_basics_username_admin_tooltip": "Вы Администратор", + "account_basics_password_title": "Пароль", + "account_basics_username_description": "Это Вы! :)", + "account_basics_password_description": "Смена пароля учетной записи", + "account_basics_password_dialog_title": "Смена пароля", + "account_basics_password_dialog_current_password_label": "Текущий пароль", + "account_basics_password_dialog_current_password_incorrect": "Введен неверный пароль", + "account_usage_title": "Использование", + "account_usage_of_limit": "из {{limit}}", + "account_usage_unlimited": "Неограниченно", + "account_usage_limits_reset_daily": "Ограничения сбрасываются ежедневно в полночь (UTC)", + "account_basics_tier_description": "Уровень Вашей учетной записи", + "account_basics_tier_admin": "Администратор", + "account_basics_tier_admin_suffix_with_tier": "(с {{tier}} подпиской)", + "account_basics_tier_payment_overdue": "У Вас задолженность по оплате. Пожалуйста проверьте метод оплаты, иначе Вы скоро потеряете преимущества Вашей подписки.", + "account_basics_tier_canceled_subscription": "Ваша подписка была отменена; учетная запись перейдет на бесплатное обслуживание {{date}}.", + "account_basics_tier_manage_billing_button": "Управление оплатой", + "account_usage_messages_title": "Опубликованные сообщения", + "account_usage_emails_title": "Отправленные электронные сообщения", + "account_usage_reservations_title": "Зарезервированные темы", + "account_usage_reservations_none": "Нет зарезервированных тем", + "account_usage_attachment_storage_title": "Хранение вложений", + "account_usage_attachment_storage_description": "{{filesize}} за файл, удаляются спустя {{expiry}}", + "account_usage_cannot_create_portal_session": "Невозможно открыть портал оплаты", + "account_delete_title": "Удалить учетную запись", + "account_delete_description": "Безвозвратно удалить Вашу учетную запись", + "account_upgrade_dialog_button_redirect_signup": "Зарегистрироваться", + "account_upgrade_dialog_button_pay_now": "Оплатить и подписаться", + "account_upgrade_dialog_button_cancel_subscription": "Отменить подписку", + "account_upgrade_dialog_button_update_subscription": "Изменить подписку", + "account_tokens_title": "Токены доступа", + "account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите документацию чтобы узнать больше.", + "account_tokens_table_token_header": "Токен", + "account_tokens_table_label_header": "Название", + "account_tokens_table_last_access_header": "Последний доступ", + "account_tokens_table_expires_header": "Истекает", + "account_tokens_dialog_label": "Название, например Radarr notifications", + "prefs_reservations_title": "Зарезервированные темы", + "prefs_reservations_description": "Здесь Вы можете резервировать темы для личного пользования. Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", + "prefs_reservations_limit_reached": "Вы исчерпали Ваш лимит на количество зарезервированных тем.", + "prefs_reservations_add_button": "Добавить тему", + "prefs_reservations_edit_button": "Настройка доступа", + "prefs_reservations_delete_button": "Сбросить правила доступа", + "prefs_reservations_table_everyone_read_only": "Я могу публиковать и подписываться, все остальные могут подписываться", + "prefs_reservations_dialog_access_label": "Доступ", + "reservation_delete_dialog_description": "Удаление резервирования дает возможность зарезервировать эту тему другим. Вы можете оставить или удалить существующие сообщения и вложения.", + "reservation_delete_dialog_action_keep_title": "Сохранить сообщения в кэше и вложения", + "reservation_delete_dialog_submit_button": "Удалить резервирование", + "account_basics_tier_basic": "Базовый", + "nav_upgrade_banner_description": "Зарезервированные темы, больше сообщений и электронных писем, а также вложения большего размера", + "alert_not_supported_context_description": "Уведомления поддерживаются только по протоколу HTTPS. Это ограничение Notifications API.", + "notifications_delete": "Удалить", + "notifications_new_indicator": "Новое уведомление", + "notifications_actions_http_request_title": "Сделать HTTP {{method}}-запрос на {{url}}", + "display_name_dialog_placeholder": "Псевдоним", + "account_basics_password_dialog_new_password_label": "Новый пароль", + "account_basics_password_dialog_confirm_password_label": "Подтвердите пароль", + "account_basics_password_dialog_button_submit": "Сменить пароль", + "account_basics_tier_title": "Тип учетной записи", + "error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.

Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в этом отчете на GitHub или связавшись с нами через Discord или Matrix.", + "account_basics_tier_interval_monthly": "ежемесячно", + "account_basics_tier_interval_yearly": "ежегодно", + "account_upgrade_dialog_interval_yearly": "Ежегодно", + "account_upgrade_dialog_interval_yearly_discount_save": "скидка {{discount}}%", + "account_upgrade_dialog_interval_monthly": "Ежемесячно", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "скидка до {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "Нет зарезервированных тем", + "account_upgrade_dialog_tier_price_per_month": "в месяц", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} в год. Оплата помесячно.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ежегодно. Сэкономьте {{save}}.", + "account_upgrade_dialog_billing_contact_email": "По вопросам оплаты, пожалуйста свяжитесь с нами.", + "account_upgrade_dialog_billing_contact_website": "По вопросам оплаты, пожалуйста обратитесь к нашему сайту.", + "publish_dialog_call_reset": "Удалить вызов", + "account_basics_phone_numbers_dialog_description": "Для того что бы использовать возможность уведомлений о вызовах, нужно добавить и проверить хотя бы один номер телефона. Проверить можно используя SMS или звонок.", + "account_basics_phone_numbers_dialog_title": "Добавить номер телефона", + "account_basics_phone_numbers_dialog_number_placeholder": "например +1222333444", + "account_basics_phone_numbers_dialog_code_placeholder": "например 123456", + "account_basics_phone_numbers_dialog_verify_button_sms": "Отправить SMS", + "account_usage_calls_title": "Совершённые вызовы", + "account_usage_calls_none": "Невозможно совершать вызовы с этим аккаунтом", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Нет проверенных номеров", + "account_basics_phone_numbers_copied_to_clipboard": "Номер телефона скопирован в буфер обмена", + "account_upgrade_dialog_tier_features_no_calls": "Нет вызовов", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} ежедневный звонок", + "account_basics_phone_numbers_dialog_number_label": "Номер телефона", + "account_basics_phone_numbers_dialog_check_verification_button": "Подтвердить код", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} ежедневных звонков", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервированная тема", + "account_basics_phone_numbers_no_phone_numbers_yet": "Телефонных номеров пока нет", + "publish_dialog_chip_call_label": "Звонок", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} ежедневное письмо", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} ежедневное сообщения", + "account_basics_phone_numbers_description": "Для уведомлений о телефонных звонках", + "publish_dialog_call_label": "Звонок", + "account_basics_phone_numbers_dialog_channel_call": "Позвонить", + "account_basics_phone_numbers_title": "Номера телефонов", + "account_basics_phone_numbers_dialog_code_label": "Проверочный код", + "account_basics_phone_numbers_dialog_verify_button_call": "Позвонить мне", + "publish_dialog_call_item": "Вызов телефонного номера {{number}}", + "account_basics_phone_numbers_dialog_channel_sms": "SMS" } diff --git a/web/public/static/langs/sk.json b/web/public/static/langs/sk.json new file mode 100644 index 00000000..0e3f57a7 --- /dev/null +++ b/web/public/static/langs/sk.json @@ -0,0 +1,384 @@ +{ + "common_save": "Uložiť", + "common_back": "Späť", + "common_copy_to_clipboard": "Kopírovať do schránky", + "signup_title": "Vytvoriť ntfy účet", + "signup_form_username": "Používateľské meno", + "signup_form_confirm_password": "Potvrdenie hesla", + "signup_form_button_submit": "Zaregistrovať sa", + "signup_form_toggle_password_visibility": "Prepnúť viditeľnosť hesla", + "signup_error_username_taken": "Používateľské meno {{username}} je už obsadené", + "login_form_button_submit": "Prihlásiť sa", + "login_disabled": "Prihlásenie je zakázané", + "action_bar_logo_alt": "ntfy logo", + "action_bar_settings": "Nastavenia", + "action_bar_account": "Účet", + "action_bar_sign_in": "Prihlásiť sa", + "action_bar_profile_settings": "Nastavenia", + "action_bar_reservation_edit": "Zmeniť rezerváciu", + "action_bar_unsubscribe": "Odhlásiť odber", + "action_bar_toggle_mute": "Stlmiť/zrušiť stlmenie upozornení", + "action_bar_toggle_action_menu": "Otvoriť/zavrieť akčné menu", + "action_bar_profile_title": "Profil", + "nav_button_settings": "Nastavenia", + "nav_button_account": "Účet", + "message_bar_show_dialog": "Zobraziť okno pre odosielanie oznámení", + "message_bar_publish": "Zverejniť správu", + "nav_topics_title": "Odoberané témy", + "nav_button_all_notifications": "Všetky oznámenia", + "alert_grant_description": "Udeliť prehliadaču povolenie na zobrazovanie oznámení na ploche.", + "alert_not_supported_context_description": "Oznámenia sú podporované len cez HTTPS. Ide o obmedzenie rozhrania Notifications API.", + "notifications_list": "Zoznam oznámení", + "notifications_list_item": "Oznámenie", + "notifications_mark_read": "Označiť ako prečítané", + "notifications_delete": "Zmazať", + "notifications_copied_to_clipboard": "Skopírované do schránky", + "notifications_tags": "Štítky", + "notifications_priority_x": "Priorita {{priority}}", + "notifications_new_indicator": "Nové oznámenie", + "notifications_attachment_image": "Obrázok prílohy", + "notifications_attachment_link_expired": "odkaz na stiahnutie vypršal", + "notifications_attachment_file_image": "súbor s obrázkom", + "notifications_attachment_file_video": "video súbor", + "notifications_attachment_file_audio": "zvukový súbor", + "notifications_attachment_file_app": "Súbor aplikácie pre Android", + "notifications_attachment_file_document": "iný dokument", + "notifications_click_copy_url_title": "Skopírovať URL adresu odkazu do schránky", + "notifications_click_copy_url_button": "Kopírovať odkaz", + "notifications_click_open_button": "Otvoriť odkaz", + "notifications_actions_not_supported": "Akcia nie je podporovaná vo webovej aplikácii", + "notifications_none_for_topic_title": "K tejto téme ste zatiaľ nedostali žiadne upozornenia.", + "notifications_none_for_any_title": "Nedostali ste žiadne upozornenia.", + "notifications_none_for_any_description": "Ak chcete posielať oznámenia do témy, jednoducho zadajte adresu PUT alebo POST na adresu URL témy. Tu je príklad s použitím jednej z vašich tém.", + "notifications_no_subscriptions_title": "Zdá sa, že zatiaľ nemáte žiadne prihlásenia na odber.", + "display_name_dialog_title": "Zmeniť zobrazovaný názov", + "notifications_no_subscriptions_description": "Kliknutím na odkaz \"{{text odkazu}}\" vytvoríte tému alebo sa na ňu prihlásite. Potom môžete posielať správy prostredníctvom PUT alebo POST a budete tu dostávať oznámenia.", + "notifications_example": "Príklad", + "notifications_more_details": "Ďalšie informácie nájdete na webovej stránke alebo v dokumentácií.", + "display_name_dialog_placeholder": "Zobrazený názov", + "reserve_dialog_checkbox_label": "Rezervovať tému a nakonfigurovať prístup", + "notifications_loading": "Načítavanie oznámení …", + "publish_dialog_title_no_topic": "Zverejniť oznámenie", + "publish_dialog_title_topic": "Zverejniť v {{topic}}", + "publish_dialog_progress_uploading": "Nahrávanie…", + "publish_dialog_progress_uploading_detail": "Nahrávanie {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Oznámenie zverejnené", + "publish_dialog_attachment_limits_file_and_quota_reached": "prekročí {{fileSizeLimit}} limit súboru a kvótu, {{remainingBytes}} zostáva", + "publish_dialog_attachment_limits_file_reached": "prekračuje {{fileSizeLimit}} limit súboru", + "publish_dialog_attachment_limits_quota_reached": "prekračuje kvótu, {{remainingBytes}} zostáva", + "publish_dialog_emoji_picker_show": "Vyberte emoji", + "publish_dialog_priority_min": "Min. priorita", + "publish_dialog_priority_low": "Nízka priorita", + "publish_dialog_priority_default": "Predvolená priorita", + "publish_dialog_priority_high": "Vysoká priorita", + "publish_dialog_priority_max": "Max. priorita", + "publish_dialog_base_url_label": "URL Adresa služby", + "publish_dialog_base_url_placeholder": "URL adresa služby, napr. https://example.com", + "publish_dialog_topic_label": "Názov témy", + "publish_dialog_topic_placeholder": "Názov témy, napr. phil_alerts", + "publish_dialog_topic_reset": "Resetovať tému", + "publish_dialog_title_label": "Názov", + "publish_dialog_title_placeholder": "Názov oznámenia, napr. Upozornenie na miesto na disku", + "publish_dialog_tags_label": "Štítky", + "publish_dialog_message_label": "Správa", + "publish_dialog_priority_label": "Priorita", + "publish_dialog_click_label": "Kliknite na URL", + "publish_dialog_click_placeholder": "URL adresa sa otvorí po kliknutí na oznámenie", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Emailová adresa, na ktorú sa má oznámenie zaslať, napr. phil@example.com", + "publish_dialog_call_label": "Telefonovať", + "publish_dialog_call_item": "Zavolať na telefónne číslo {{number}}", + "publish_dialog_call_reset": "Odstrániť telefón", + "publish_dialog_attach_label": "URL prílohy", + "publish_dialog_attach_reset": "Odstrániť URL prílohy", + "publish_dialog_filename_label": "Názov súboru", + "publish_dialog_filename_placeholder": "Názov súboru prílohy", + "publish_dialog_delay_label": "Oneskorenie", + "publish_dialog_delay_placeholder": "Oneskorenie doručenia, napr. {{unixTimestamp}}, {{relativeTime}} alebo \"{{naturalLanguage}}\" (len v angličtine)", + "publish_dialog_delay_reset": "Odstrániť oneskorené doručenie", + "publish_dialog_chip_call_label": "Telefonovať", + "publish_dialog_other_features": "Ďalšie funkcie:", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Žiadne overené telefónne čísla", + "publish_dialog_chip_attach_url_label": "Pripojiť súbor pomocou adresy URL", + "publish_dialog_chip_delay_label": "Oneskoriť doručenie", + "publish_dialog_chip_topic_label": "Zmeniť tému", + "publish_dialog_button_cancel_sending": "Zrušiť odosielanie", + "publish_dialog_button_send": "Odoslať", + "publish_dialog_checkbox_publish_another": "Zverejniť ďalšie", + "publish_dialog_attached_file_title": "Priložený súbor:", + "subscribe_dialog_subscribe_button_cancel": "Zrušiť", + "subscribe_dialog_subscribe_title": "Odoberať tému", + "subscribe_dialog_subscribe_base_url_label": "URL Adresa služby", + "subscribe_dialog_subscribe_topic_placeholder": "Názov témy, napr. phil_alerts", + "publish_dialog_attached_file_filename_placeholder": "Názov súboru prílohy", + "publish_dialog_attached_file_remove": "Odstrániť priložený súbor", + "publish_dialog_drop_file_here": "Vložiť súbor", + "subscribe_dialog_login_password_label": "Heslo", + "account_basics_password_dialog_confirm_password_label": "Potvrdenie hesla", + "account_basics_title": "Účet", + "account_delete_dialog_button_cancel": "Zrušiť", + "account_delete_dialog_label": "Heslo", + "prefs_reservations_dialog_title_add": "Rezervovať tému", + "publish_dialog_button_cancel": "Zrušiť", + "account_upgrade_dialog_button_cancel": "Zrušiť", + "account_tokens_dialog_button_cancel": "Zrušiť", + "common_cancel": "Zrušiť", + "common_add": "Pridať", + "account_basics_username_title": "Používateľské meno", + "signup_form_password": "Heslo", + "signup_error_creation_limit_reached": "Dosiahnutý limit na vytvorenie konta", + "account_basics_password_title": "Heslo", + "action_bar_change_display_name": "Zmeniť zobrazovaný názov", + "prefs_users_dialog_password_label": "Heslo", + "action_bar_sign_up": "Zaregistrovať sa", + "login_link_signup": "Zaregistrovať sa", + "signup_already_have_account": "Už máte účet? Prihláste sa!", + "signup_disabled": "Registrácia je vypnutá", + "login_title": "Prihláste sa do svojho konta ntfy", + "action_bar_show_menu": "Zobraziť menu", + "action_bar_reservation_add": "Rezervovať tému", + "action_bar_reservation_delete": "Odstrániť rezerváciu", + "action_bar_reservation_limit_reached": "Dosiahnutý limit", + "action_bar_send_test_notification": "Odoslať testovacie oznámenie", + "action_bar_clear_notifications": "Vymazať všetky oznámenia", + "publish_dialog_message_placeholder": "Sem napíšte správu", + "action_bar_profile_logout": "Odhlásiť sa", + "message_bar_type_message": "Sem napíšte správu", + "message_bar_error_publishing": "Chyba pri zverejňovaní oznámenia", + "nav_button_documentation": "Dokumentácia", + "nav_button_publish_message": "Zverejniť oznámenie", + "nav_button_subscribe": "Odoberať tému", + "nav_button_muted": "Oznámenia stlmené", + "nav_button_connecting": "pripájanie", + "nav_upgrade_banner_description": "Rezervovať témy, viac správ a e-mailov a väčšie prílohy", + "nav_upgrade_banner_label": "Vylepšiť na ntfy Pro", + "alert_grant_title": "Oznámenia sú vypnuté", + "alert_grant_button": "Prideliť teraz", + "alert_not_supported_title": "Oznámenia nie sú podporované", + "alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované.", + "notifications_attachment_copy_url_title": "Kopírovať URL adresu prílohy do schránky", + "notifications_attachment_copy_url_button": "Kopírovať adresu URL", + "notifications_attachment_open_title": "Prejsť na {{url}}", + "notifications_actions_open_url_title": "Prejsť na {{url}}", + "notifications_attachment_open_button": "Otvoriť prílohu", + "notifications_attachment_link_expires": "platnosť odkazu vyprší {{date}}", + "notifications_none_for_topic_description": "Ak chcete posielať oznámenia do tejto témy, jednoducho zadajte adresu PUT alebo POST na URL adresu témy.", + "notifications_actions_http_request_title": "Odoslať HTTP {{method}} na {{url}}", + "display_name_dialog_description": "Nastavenie alternatívneho názvu témy, ktorá sa zobrazuje v zozname odberov. Pomáha to ľahšie identifikovať témy so zložitými názvami.", + "prefs_users_table_base_url_header": "URL Adresa služby", + "publish_dialog_tags_placeholder": "Zoznam štítkov oddelených čiarkou, napr. varovanie, srv1-backup", + "publish_dialog_chip_click_label": "Kliknite na URL", + "publish_dialog_email_reset": "Odstrániť email na preposielanie", + "publish_dialog_click_reset": "Odobrať URL kliknutím", + "publish_dialog_attach_placeholder": "Pripojiť súbor pomocou URL adresy, napr. https://f-droid.org/F-Droid.apk", + "publish_dialog_chip_email_label": "Preposlanie na email", + "publish_dialog_chip_attach_file_label": "Pripojiť miestny súbor", + "publish_dialog_details_examples_description": "Príklady a podrobný opis všetkých funkcií odosielania nájdete v dokumentácii.", + "account_upgrade_dialog_tier_features_no_calls": "Žiadne telefonáty", + "account_upgrade_dialog_billing_contact_email": "V prípade otázok týkajúcich sa fakturácie nás prosím kontaktujte tu.", + "account_tokens_dialog_title_create": "Vytvoriť prístupový token", + "prefs_reservations_dialog_title_edit": "Upraviť rezervovanú tému", + "account_basics_tier_interval_monthly": "mesačne", + "account_basics_tier_canceled_subscription": "Vaše predplatné bolo zrušené a bude preradené na bezplatné konto k dátumu {{date}}.", + "priority_default": "predvolená", + "prefs_notifications_min_priority_title": "Najnižšia priorita", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} denný telefonát", + "account_upgrade_dialog_tier_current_label": "Aktuálne", + "account_basics_password_dialog_current_password_incorrect": "Nesprávne heslo", + "account_tokens_table_token_header": "Token", + "prefs_notifications_delete_after_never": "Nikdy", + "prefs_users_description": "Tu môžete pridávať/odstraňovať používateľov pre svoje chránené témy. Upozorňujeme, že používateľské meno a heslo sú uložené v lokálnom úložisku prehliadača.", + "account_basics_phone_numbers_dialog_number_label": "Telefónne číslo", + "subscribe_dialog_subscribe_description": "Témy nemusia byť chránené heslom, preto vyberte názov, ktorý nie je ľahké uhádnuť. Po prihlásení sa na odber môžete PUT/POST oznámenia.", + "account_basics_password_dialog_button_submit": "Zmeniť heslo", + "account_basics_phone_numbers_dialog_check_verification_button": "Potvrdiť kód", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetrite až {{discount}}%", + "account_tokens_dialog_label": "Označenie, napr. Radarr notifications", + "account_tokens_table_expires_header": "Vyprší", + "account_upgrade_dialog_proration_info": "Vyhlásenie: Pri prechode medzi platenými plánmi sa rozdiel v cene účtuje okamžite. Pri prechode na nižšiu úroveň sa zostatok použije na platbu za budúce fakturačné obdobia.", + "prefs_reservations_dialog_access_label": "Prístup", + "account_usage_attachment_storage_title": "Ukladanie príloh", + "prefs_users_dialog_username_label": "Používateľské meno, napr. phil", + "account_usage_messages_title": "Zverejnené správy", + "emoji_picker_search_clear": "Vymazať vyhľadávanie", + "prefs_reservations_table_not_subscribed": "Odber nie je prihlásený", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} denné emaily", + "prefs_notifications_min_priority_max_only": "Iba najvyššia priorita", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} denné telefonáty", + "prefs_notifications_sound_description_some": "Oznámenia pri príchode prehrávajú zvuk {{sound}}", + "prefs_reservations_edit_button": "Upraviť prístup k téme", + "account_basics_phone_numbers_dialog_verify_button_sms": "Poslať SMS", + "account_basics_tier_change_button": "Zmeniť", + "account_tokens_dialog_expires_never": "Platnosť tokenu nikdy nevyprší", + "subscribe_dialog_login_title": "Vyžaduje sa prihlásenie", + "account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní", + "prefs_reservations_table_everyone_read_only": "Môžem publikovať a odoberať, každý môže odoberať", + "prefs_reservations_table_everyone_deny_all": "Iba ja môžem publikovať a odoberať", + "account_basics_phone_numbers_dialog_description": "Ak chcete používať funkciu oznamovanie hovorom, musíte pridať a overiť aspoň jedno telefónne číslo. Overenie je možné vykonať prostredníctvom SMS alebo telefonického hovoru.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervovaná téma", + "account_delete_title": "Odstrániť účet", + "subscribe_dialog_login_button_login": "Prihlásenie", + "account_upgrade_dialog_tier_features_no_reservations": "Žiadne rezervované témy", + "prefs_users_table_cannot_delete_or_edit": "Nie je možné odstrániť alebo upraviť prihláseného používateľa", + "account_basics_tier_admin_suffix_with_tier": "(s úrovňou {{tier}})", + "prefs_notifications_delete_after_three_hours_description": "Oznámenia sa automaticky odstránia po troch hodinách", + "prefs_notifications_delete_after_three_hours": "Po troch hodinách", + "prefs_notifications_min_priority_description_x_or_higher": "Zobraziť oznámenia, ak je priorita {{number}} ({{name}}) alebo vyššia", + "reservation_delete_dialog_description": "Odstránením rezervácie sa vzdáte vlastníctva témy a umožníte ostatným, aby si ju rezervovali. Existujúce správy a prílohy si môžete ponechať alebo odstrániť.", + "subscribe_dialog_login_username_label": "Používateľské meno, napr. phil", + "subscribe_dialog_error_user_not_authorized": "Používateľ {{username}} nie je autorizovaný", + "prefs_reservations_table_everyone_read_write": "Každý môže publikovať a odoberať", + "prefs_reservations_dialog_title_delete": "Odstrániť rezervovanú tému", + "prefs_users_table": "Tabuľka používateľov", + "prefs_reservations_table_topic_header": "Téma", + "reservation_delete_dialog_submit_button": "Vymazať rezerváciu", + "prefs_reservations_limit_reached": "Dosiahli ste limit rezervovaných tém.", + "account_upgrade_dialog_interval_monthly": "Mesačne", + "prefs_users_add_button": "Pridať používateľa", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} denné správy", + "account_basics_phone_numbers_no_phone_numbers_yet": "Zatiaľ žiadne telefónne čísla", + "subscribe_dialog_subscribe_button_generate_topic_name": "Vygenerovať názov", + "prefs_appearance_language_title": "Jazyk", + "prefs_notifications_delete_after_one_day_description": "Oznámenia sa automaticky odstránia po jednom dni", + "subscribe_dialog_subscribe_button_subscribe": "Odoberať", + "account_tokens_table_never_expires": "Nikdy nevyprší", + "account_tokens_delete_dialog_title": "Odstrániť prístupový token", + "prefs_notifications_delete_after_one_month": "Po jednom mesiaci", + "account_basics_phone_numbers_dialog_title": "Pridať telefónne číslo", + "account_tokens_delete_dialog_description": "Pred odstránením prístupového tokenu sa uistite, že ho aktívne nepoužívajú žiadne aplikácie ani skripty. Túto akciu nie je možné vrátiť späť.", + "account_tokens_table_label_header": "Označenie", + "account_upgrade_dialog_billing_contact_website": "Otázky týkajúce sa fakturácie nájdete na našej webovej stránke.", + "account_basics_username_admin_tooltip": "Ste Admin", + "prefs_notifications_delete_after_never_description": "Oznámenia sa nikdy automaticky neodstránia", + "account_delete_dialog_description": "Tým sa vaše konto natrvalo odstráni vrátane všetkých údajov uložených na serveri. Po vymazaní bude vaše používateľské meno 7 dní nedostupné. Ak naozaj chcete pokračovať, potvrďte svoje heslo v poli nižšie.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} rezervované témy", + "account_usage_reservations_none": "Žiadne rezervované témy pre toto konto", + "prefs_notifications_sound_description_none": "Pri príchode oznámení sa neprehráva žiadny zvuk", + "account_tokens_description": "Pri publikovaní a prihlasovaní prostredníctvom rozhrania ntfy API používajte prístupové tokeny, aby ste nemuseli posielať prihlasovacie údaje k účtu. Viacej informácií nájdete v dokumentácií.", + "prefs_reservations_table": "Tabuľka rezervovaných tém", + "emoji_picker_search_placeholder": "Vyhľadať emoji", + "account_upgrade_dialog_button_cancel_subscription": "Zrušiť predplatné", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denný email", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na jeden súbor", + "prefs_reservations_description": "Tu si môžete rezervovať názvy tém na osobné použitie. Rezervovaním témy získate vlastníctvo nad témou a môžete definovať prístupové práva pre ostatných používateľov k téme.", + "account_usage_title": "Používanie", + "account_basics_tier_upgrade_button": "Vylepšiť na PRO verziu", + "prefs_users_description_no_sync": "Používatelia a heslá nie sú synchronizované s vaším účtom.", + "account_tokens_dialog_title_edit": "Upraviť prístupový token", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} denná správa", + "account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne vymažte aspoň jednu rezerváciu. Rezervácie môžete odstrániť v Nastaveniach.", + "subscribe_dialog_error_topic_already_reserved": "Téma je už rezervovaná", + "prefs_users_table_user_header": "Používateľ", + "error_boundary_stack_trace": "Výpis zásobníka", + "prefs_notifications_delete_after_one_week": "Po jednom týždni", + "prefs_reservations_delete_button": "Resetovať prístup k téme", + "account_basics_tier_admin_suffix_no_tier": "(bez úrovne)", + "prefs_notifications_delete_after_one_week_description": "Oznámenia sa automaticky odstránia po jednom týždni", + "error_boundary_unsupported_indexeddb_description": "Webová aplikácia ntfy potrebuje na fungovanie IndexedDB a váš prehliadač nepodporuje IndexedDB v režime súkromného prehliadania.

Je to síce nešťastné, ale aj tak nemá veľký zmysel používať webovú aplikáciu ntfy v režime súkromného prehliadania, pretože všetko je uložené v úložisku prehliadača. Viac informácií si môžete prečítať v tomto probléme GitHubu alebo sa s nami porozprávať na Discord alebo Matrix.", + "account_basics_tier_payment_overdue": "Vaša platba je po termíne splatnosti. Aktualizujte prosím svoj spôsob platby, inak bude váš účet preradený do nižšej kategórie.", + "account_basics_tier_description": "Úroveň výkonu vášho účtu", + "account_basics_phone_numbers_description": "Pre oznamovanie hovorom", + "account_basics_tier_free": "Zadarmo", + "account_upgrade_dialog_cancel_warning": "Týmto zrušíte svoje predplatné a {{date}} prejdete na nižšiu úroveň svojho účtu. V tento deň budú odstránené rezervácie tém, ako aj správy uložené vo vyrovnávacej pamäti servera.", + "account_basics_tier_admin": "Admin", + "prefs_notifications_sound_title": "Zvuk oznámenia", + "prefs_notifications_min_priority_default_and_higher": "Predvolená priorita a vyššia", + "prefs_reservations_table_access_header": "Prístup", + "account_tokens_table_copied_to_clipboard": "Prístupový token skopírovaný", + "account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodín", + "prefs_users_edit_button": "Upraviť používateľa", + "account_upgrade_dialog_title": "Zmeniť úroveň účtu", + "priority_low": "nízka", + "prefs_reservations_table_click_to_subscribe": "Kliknutím sa prihlásite na odber", + "account_basics_password_description": "Zmeniť heslo účtu", + "account_usage_calls_title": "Uskutočnené telefonické hovory", + "error_boundary_description": "Toto samozrejme nemalo nastať. Je mi to veľmi ľúto.
Ak máte chvíľu, nahláste to na GitHub alebo nám dajte vedieť cez Discord alebo Matrix.", + "priority_min": "najnižšia", + "account_basics_tier_basic": "Základný", + "prefs_notifications_min_priority_description_any": "Zobraziť všetky oznámenia bez ohľadu na prioritu", + "error_boundary_gathering_info": "Získajte viac informácií…", + "error_boundary_unsupported_indexeddb_title": "Súkromné prehliadanie nie je podporované", + "prefs_notifications_delete_after_one_day": "Po jednom dni", + "error_boundary_title": "Ale nie, ntfy prestalo fungovať", + "reservation_delete_dialog_action_keep_description": "Správy a prílohy, ktoré sú uložené v medzipamäti na serveri, budú verejne viditeľné pre ľudí, ktorí poznajú názov témy.", + "prefs_reservations_add_button": "Pridať rezervovanú tému", + "prefs_reservations_title": "Rezervované témy", + "account_basics_phone_numbers_copied_to_clipboard": "Telefónne číslo skopírované do schránky", + "prefs_reservations_dialog_description": "Rezervovaním témy získate vlastníctvo nad témou a môžete definovať prístupové práva pre ostatných používateľov k téme.", + "account_basics_tier_title": "Typ účtu", + "account_usage_cannot_create_portal_session": "Nemožnosť otvoriť fakturačný portál", + "account_tokens_delete_dialog_submit_button": "Trvalo odstrániť token", + "account_delete_description": "Natrvalo odstrániť vaše konto", + "account_basics_phone_numbers_dialog_number_placeholder": "napr. +1222333444", + "account_basics_phone_numbers_dialog_code_placeholder": "napr. 123456", + "prefs_notifications_title": "Oznámenia", + "account_basics_tier_manage_billing_button": "Spravovať fakturáciu", + "account_tokens_title": "Prístupové tokeny", + "account_basics_username_description": "Hej, to si ty ❤", + "prefs_reservations_dialog_topic_label": "Téma", + "prefs_users_title": "Správa používateľov", + "account_basics_tier_interval_yearly": "ročne", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje sa mesačne.", + "account_delete_dialog_button_submit": "Natrvalo odstrániť konto", + "account_basics_phone_numbers_dialog_channel_call": "Hovor", + "account_basics_password_dialog_new_password_label": "Nové heslo", + "account_tokens_dialog_expires_unchanged": "Ponechať dátum skončenia platnosti nezmenený", + "error_boundary_button_copy_stack_trace": "Kopírovať výpis zásobníka", + "account_tokens_dialog_title_delete": "Odstrániť prístupový token", + "account_usage_of_limit": "z {{limit}}", + "reservation_delete_dialog_action_keep_title": "Ponechať správy a prílohy uložené v medzipamäti", + "prefs_notifications_sound_no_sound": "Bez zvuku", + "account_upgrade_dialog_interval_yearly": "Ročne", + "account_upgrade_dialog_button_redirect_signup": "Zaregistrujte sa teraz", + "subscribe_dialog_error_user_anonymous": "anonymný", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtovaná ročne. Uložiť {{save}}.", + "prefs_notifications_min_priority_high_and_higher": "Vysoká priorita a vyššia", + "account_usage_basis_ip_description": "Štatistiky a limity používania tohto účtu sú založené na vašej IP adrese, takže môžu byť zdieľané s ostatnými používateľmi. Vyššie uvedené limity sú približné hodnoty založené na existujúcich rýchlostných limitoch.", + "account_basics_password_dialog_title": "Zmeniť heslo", + "priority_max": "najvyššia", + "account_usage_limits_reset_daily": "Limity používania sa obnovujú denne o polnoci (UTC)", + "account_usage_unlimited": "Nekonečné", + "prefs_users_delete_button": "Odstrániť používateľa", + "prefs_notifications_min_priority_any": "Akákoľvek priorita", + "account_tokens_dialog_expires_label": "Platnosť prístupového tokenu vyprší za", + "account_basics_phone_numbers_title": "Telefónne čísla", + "prefs_notifications_delete_after_title": "Odstrániť oznámenia", + "account_upgrade_dialog_interval_yearly_discount_save": "ušetríte {{discount}}%", + "prefs_users_dialog_title_edit": "Upraviť používateľa", + "account_basics_password_dialog_current_password_label": "Aktuálne heslo", + "prefs_notifications_min_priority_low_and_higher": "Nízka priorita a vyššia", + "account_tokens_dialog_button_update": "Aktualizovať token", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný priestor", + "prefs_reservations_table_everyone_write_only": "Môžem publikovať a odoberať, každý môže publikovať", + "prefs_appearance_title": "Vzhlad", + "account_tokens_table_cannot_delete_or_edit": "Nie je možné upraviť alebo odstrániť aktuálny token relácie", + "prefs_notifications_sound_play": "Prehrať vybraný zvuk", + "account_tokens_table_last_access_header": "Posledný prístup", + "account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, kliknite na vyhľadávanie", + "account_usage_reservations_title": "Rezervované témy", + "account_upgrade_dialog_tier_price_per_month": "mesiac", + "account_usage_calls_none": "S týmto účtom nie je možné uskutočňovať žiadne telefonické hovory", + "account_tokens_table_current_session": "Aktuálna relácia prehliadača", + "account_upgrade_dialog_button_pay_now": "Zaplatiť a predplatiť si", + "subscribe_dialog_subscribe_use_another_label": "Použiť iný server", + "reservation_delete_dialog_action_delete_title": "Odstrániť správy a prílohy uložené v medzipamäti", + "account_basics_phone_numbers_dialog_code_label": "Overovací kód", + "reservation_delete_dialog_action_delete_description": "Správy a prílohy uložené v medzipamäti sa natrvalo vymažú. Túto akciu nemožno vrátiť späť.", + "account_basics_tier_paid_until": "Predplatné zaplatené do {{date}} s automatickou obnovou", + "account_usage_attachment_storage_description": "{{filesize}} na súbor, vymazaný po {{expiry}}", + "prefs_notifications_delete_after_one_month_description": "Oznámenia sa automaticky odstránia po jednom mesiaci", + "account_basics_phone_numbers_dialog_verify_button_call": "Zavolajte mi", + "prefs_users_dialog_base_url_label": "URL adresa služby, napr. https://ntfy.sh", + "account_usage_emails_title": "Odoslané emaily", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_selected_label": "Vybrané", + "account_upgrade_dialog_button_update_subscription": "Aktualizovať predplatné", + "priority_high": "vysoká", + "account_delete_dialog_billing_warning": "Odstránením konta sa okamžite zruší aj vaše fakturačné predplatné. Už nebudete mať prístup k fakturačnému panelu.", + "prefs_notifications_min_priority_description_max": "Zobraziť oznámenia, ak je priorita 5 (max)", + "subscribe_dialog_login_description": "Táto téma je chránená heslom. Ak sa chcete prihlásiť na odber témy, zadajte používateľské meno a heslo.", + "account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne vymažte aspoň {{count}} rezervácií. Rezervácie môžete odstrániť v Nastaveniach.", + "prefs_users_dialog_title_add": "Pridať používateľa", + "account_tokens_dialog_button_create": "Vytvoriť token", + "account_tokens_table_create_token_button": "Vytvoriť prístupový token" +} diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json new file mode 100644 index 00000000..1a44a3dc --- /dev/null +++ b/web/public/static/langs/sv.json @@ -0,0 +1,384 @@ +{ + "action_bar_settings": "Inställningar", + "action_bar_send_test_notification": "Skicka test notis", + "action_bar_toggle_action_menu": "Öppna/stäng åtgärdsmeny", + "message_bar_type_message": "Skriv ett meddelande här", + "message_bar_error_publishing": "Fel vid publicering av notis", + "message_bar_show_dialog": "Visa publicerings dialog", + "message_bar_publish": "Publicera meddelande", + "nav_topics_title": "Prenumererade kategorier", + "nav_button_all_notifications": "Alla notiser", + "nav_button_documentation": "Dokumentation", + "nav_button_publish_message": "Publicera notis", + "nav_button_subscribe": "Prenumerera på kategori", + "alert_notification_permission_required_title": "Notiser är avstängda", + "alert_notification_permission_required_button": "Bevilja nu", + "alert_not_supported_title": "Notiser stöds inte", + "notifications_list": "Notifieringslista", + "notifications_list_item": "Notis", + "notifications_delete": "Radera", + "notifications_copied_to_clipboard": "Kopierat till urklipp", + "notifications_tags": "Taggar", + "notifications_new_indicator": "Ny notis", + "notifications_attachment_copy_url_title": "Kopiera bifogad URL till urklipp", + "notifications_attachment_copy_url_button": "Kopiera URL", + "notifications_attachment_open_title": "Gå till {{url}}", + "notifications_attachment_open_button": "Öppna bilagan", + "notifications_attachment_link_expired": "Nedladdningslänk utgått", + "notifications_priority_x": "Prioritet {{priority}}", + "action_bar_show_menu": "Visa meny", + "action_bar_logo_alt": "ntfy logga", + "action_bar_unsubscribe": "Avprenumerera", + "action_bar_toggle_mute": "Tysta/aktivera notiser", + "action_bar_clear_notifications": "Rensa alla notiser", + "nav_button_connecting": "ansluter", + "notifications_attachment_image": "Bifogad bild", + "nav_button_settings": "Inställningar", + "nav_button_muted": "Notiser tystade", + "notifications_attachment_link_expires": "länken utgår {{date}}", + "notifications_attachment_file_image": "bild fil", + "notifications_attachment_file_audio": "ljud fil", + "alert_notification_permission_required_description": "Ge din webbläsare behörighet att visa skrivbordsnotiser.", + "alert_not_supported_description": "Notiser stöds inte i din webbläsare.", + "notifications_mark_read": "Markera som läst", + "notifications_attachment_file_video": "video fil", + "notifications_click_copy_url_button": "Kopiera länk", + "notifications_click_open_button": "Öppna länk", + "notifications_actions_open_url_title": "Gå till {{url}}", + "notifications_none_for_any_title": "Du har inte fått några notiser.", + "notifications_example": "Exempel", + "notifications_loading": "Laddar notiser …", + "signup_title": "Skapa ett nytt konto", + "signup_form_confirm_password": "Bekräfta lösenord", + "signup_form_button_submit": "Skapa konto", + "login_title": "Logga in på ditt konto", + "login_form_button_submit": "Logga in", + "login_link_signup": "Registrera", + "login_disabled": "Inloggning är inaktiverat", + "action_bar_account": "Konto", + "action_bar_change_display_name": "Ändra visningsnamn", + "action_bar_reservation_add": "Reservera ämne", + "action_bar_reservation_edit": "Ändra reservation", + "action_bar_reservation_delete": "Ta bort reservation", + "action_bar_reservation_limit_reached": "Gräns nådd", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Inställningar", + "action_bar_profile_logout": "Logga ut", + "action_bar_sign_in": "Logga in", + "action_bar_sign_up": "Registrera", + "nav_button_account": "Konto", + "nav_upgrade_banner_label": "Uppgradera till Pro", + "common_add": "Lägg till", + "signup_form_password": "Lösenord", + "signup_form_toggle_password_visibility": "Visa/dölj lösenord", + "common_cancel": "Avbryt", + "common_save": "Spara", + "signup_form_username": "Användarnamn", + "signup_already_have_account": "Har du redan ett konto? Logga in!", + "signup_disabled": "Registrering är inaktiverad", + "signup_error_username_taken": "Användarnamn [[username]] används redan", + "notifications_attachment_file_document": "annat dokument", + "notifications_attachment_file_app": "Android app fil", + "notifications_click_copy_url_title": "Kopiera länk till urklipp", + "notifications_none_for_topic_title": "Du har inte fått några notiser för detta ämnet ännu.", + "notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämnet, använd PUT eller POST till ämnets URL.", + "notifications_actions_http_request_title": "Skicka HTTP {{method}} till {{url}}", + "publish_dialog_progress_uploading": "Laddar upp …", + "nav_upgrade_banner_description": "Reservera ämnen, fler meddelanden och e-postmeddelanden och större bilagor", + "publish_dialog_attachment_limits_file_and_quota_reached": "överskrider {{fileSizeLimit}} filgräns och kvot, {{remainingBytes}} återstående", + "publish_dialog_attachment_limits_file_reached": "överskrider {{fileSizeLimit}} filgräns", + "publish_dialog_attachment_limits_quota_reached": "överskrider kvoten, {{remainingBytes}} återstår", + "publish_dialog_message_placeholder": "Skriv ett meddelande här", + "publish_dialog_checkbox_publish_another": "Publicera en till", + "subscribe_dialog_error_user_anonymous": "anonym", + "account_basics_password_dialog_confirm_password_label": "Bekräfta lösenord", + "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com", + "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i dokumentationen .", + "publish_dialog_button_send": "Skicka", + "common_back": "Tillbaka", + "account_basics_tier_free": "Gratis", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", + "account_delete_title": "Ta bort konto", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", + "account_upgrade_dialog_button_cancel": "Avbryt", + "common_copy_to_clipboard": "Kopiera till urklipp", + "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat", + "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i dokumentationen.", + "account_tokens_table_create_token_button": "Skapa åtkomsttoken", + "prefs_users_description_no_sync": "Användare och lösenord synkroniseras inte till ditt konto.", + "error_boundary_unsupported_indexeddb_description": "ntfy-webbappen behöver IndexedDB för att fungera och din webbläsare har inte stöd för IndexedDB i privat surfläge.

Detta är beklagligt, men det är inte heller särskilt meningsfullt att använda ntfy-webbappen i privat surfläge, eftersom allt lagras i webbläsarens lagringsutrymme. Du kan läsa mer om det i detta GitHub-ärende, eller prata med oss på Discord eller Matrix.", + "account_basics_tier_interval_monthly": "månadsvis", + "account_basics_tier_interval_yearly": "årligen", + "account_basics_tier_canceled_subscription": "Din prenumeration avbröts och kommer att nedgraderas till ett gratis konto den {{date}}.", + "account_basics_tier_manage_billing_button": "Hantera fakturering", + "account_usage_messages_title": "Publicerade meddelande", + "account_usage_emails_title": "Skickade e-postmeddelanden", + "account_usage_reservations_title": "Reserverade ämnen", + "account_usage_reservations_none": "Inga reserverade ämnen för det här kontot", + "account_usage_attachment_storage_title": "Lagring av bilagor", + "account_usage_attachment_storage_description": "{{filesize}} per fil, raderas efter {{expiry}}", + "account_delete_description": "Ta bort ditt konto permanent", + "account_delete_dialog_description": "Detta kommer att radera ditt konto permanent, inklusive all data som lagras på servern. Efter raderingen kommer ditt användarnamn att vara otillgängligt i 7 dagar. Om du verkligen vill fortsätta, bekräfta med ditt lösenord i rutan nedan.", + "account_delete_dialog_label": "Lösenord", + "account_delete_dialog_button_cancel": "Avbryt", + "account_delete_dialog_button_submit": "Ta bort kontot permanent", + "account_delete_dialog_billing_warning": "Om du raderar ditt konto annulleras också din faktureringsprenumeration omedelbart. Du kommer inte längre att ha tillgång till instrumentpanelen för fakturering.", + "account_upgrade_dialog_title": "Ändra kontonivå", + "account_upgrade_dialog_interval_monthly": "Månadsvis", + "account_upgrade_dialog_interval_yearly": "Årligen", + "account_upgrade_dialog_interval_yearly_discount_save": "spara {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spara upp till {{discount}}%", + "account_upgrade_dialog_cancel_warning": "Detta kommer att säga upp din prenumeration och nedgradera ditt konto på {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern att raderas.", + "account_upgrade_dialog_proration_info": "Deklaration: När du uppgraderar mellan betalda planer kommer prisskillnaden att debiteras omedelbart. Vid nedgradering till en lägre nivå kommer saldot att användas för att betala för framtida faktureringsperioder.", + "account_upgrade_dialog_reservations_warning_one": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, bör du ta bort minst en reservation. Du kan ta bort reservationer i Inställningar.", + "account_upgrade_dialog_reservations_warning_other": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, ta bort minst {{count}} reservationer. Du kan ta bort reservationer i Inställningar.", + "account_upgrade_dialog_tier_features_no_reservations": "Inga reserverade ämnen", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per fil", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total lagring", + "account_upgrade_dialog_tier_price_per_month": "månad", + "account_upgrade_dialog_tier_selected_label": "Vald", + "account_tokens_table_token_header": "Token", + "account_tokens_dialog_title_create": "Skapa åtkomsttoken", + "account_tokens_dialog_title_delete": "Ta bort åtkomsttoken", + "account_tokens_dialog_label": "Etikett, t.ex. Radarr-meddelanden", + "account_tokens_dialog_title_edit": "Redigera åtkomsttoken", + "account_tokens_dialog_button_create": "Skapa token", + "account_tokens_dialog_button_update": "Uppdatera token", + "account_tokens_delete_dialog_submit_button": "Ta bort token permanent", + "prefs_notifications_delete_after_one_day": "Efter en dag", + "reservation_delete_dialog_action_delete_description": "Cachade meddelanden och bilagor raderas permanent. Denna åtgärd kan inte ångras.", + "error_boundary_gathering_info": "Samla mer information …", + "error_boundary_unsupported_indexeddb_title": "Privat surfning stöds inte", + "reservation_delete_dialog_submit_button": "Ta bort reservationen", + "priority_low": "låg", + "error_boundary_title": "Åh nej, ntfy kraschade", + "error_boundary_description": "Detta får naturligtvis inte ske. Vi beklagar verkligen detta.
Om du har tid, vänligen rapportera detta på GitHub, eller meddela oss via Discord eller Matrix.", + "notifications_no_subscriptions_title": "Det ser ut som om du inte har några prenumerationer ännu.", + "notifications_more_details": "Mer information finns på webbplatsen eller i dokumentationen .", + "publish_dialog_title_topic": "Publicera till {{topic}}", + "publish_dialog_message_published": "Meddelande publicerat", + "publish_dialog_emoji_picker_show": "Välj emoji", + "publish_dialog_base_url_placeholder": "Service-URL, t.ex. https://example.com", + "publish_dialog_topic_label": "Ämnesnamn", + "publish_dialog_topic_placeholder": "Ämnesnamn, t.ex. phils_alerts", + "publish_dialog_topic_reset": "Återställ ämne", + "publish_dialog_title_label": "Titel", + "publish_dialog_title_placeholder": "Meddelandets rubrik, t.ex. Varning för diskutrymme", + "publish_dialog_tags_label": "Taggar", + "publish_dialog_message_label": "Meddelande", + "publish_dialog_tags_placeholder": "Kommaseparerad lista med taggar, t.ex. warning, srv1-backup", + "publish_dialog_priority_label": "Prioritet", + "publish_dialog_click_label": "Klicka på URL", + "publish_dialog_click_placeholder": "URL som öppnas när man klickar på anmälan", + "publish_dialog_click_reset": "Ta bort klickbar URL", + "publish_dialog_email_reset": "Ta bort vidarebefordran av e-post", + "publish_dialog_attach_label": "URL för bifogade filer", + "publish_dialog_attach_placeholder": "Bifoga fil via URL, t.ex. https://f-droid.org/F-Droid.apk", + "publish_dialog_filename_label": "Filnamn", + "publish_dialog_delay_label": "Fördröjning", + "publish_dialog_filename_placeholder": "Filnamn för bifogad fil", + "publish_dialog_delay_placeholder": "Fördröj leverans, t.ex. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (endast engelska)", + "publish_dialog_delay_reset": "Ta bort försenad leverans", + "publish_dialog_other_features": "Andra funktioner:", + "publish_dialog_chip_click_label": "Klicka på URL", + "publish_dialog_attached_file_title": "Bifogad fil:", + "publish_dialog_attached_file_filename_placeholder": "Filnamn för bifogad fil", + "emoji_picker_search_placeholder": "Sök emoji", + "subscribe_dialog_subscribe_button_cancel": "Avbryt", + "prefs_notifications_sound_description_some": "Meddelanden spelar upp ljudet {{sound}} när de anländer", + "prefs_notifications_sound_no_sound": "Inget ljud", + "prefs_notifications_min_priority_any": "Alla prioriteringar", + "prefs_notifications_min_priority_low_and_higher": "Låg prioritet och högre", + "prefs_notifications_delete_after_three_hours": "Efter tre timmar", + "prefs_notifications_delete_after_never": "Aldrig", + "prefs_users_table": "Användartabell", + "prefs_users_add_button": "Lägg till användare", + "prefs_users_edit_button": "Redigera användare", + "prefs_users_dialog_title_add": "Lägg till användare", + "prefs_users_dialog_title_edit": "Redigera användare", + "prefs_users_dialog_base_url_label": "Tjänstens URL, t.ex. https://ntfy.sh", + "prefs_users_dialog_password_label": "Lösenord", + "prefs_appearance_title": "Utseende", + "prefs_appearance_language_title": "Språk", + "priority_min": "min", + "priority_default": "standard", + "priority_high": "hög", + "priority_max": "max", + "error_boundary_button_copy_stack_trace": "Kopiera stackspårning", + "error_boundary_stack_trace": "Stackspårning", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverade ämnen", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} dagligt meddelande", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} dagliga e-postmeddelanden", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per år. Faktureras månadsvis.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} faktureras årligen. Spara {{save}}.", + "account_upgrade_dialog_tier_current_label": "Aktuell", + "account_upgrade_dialog_billing_contact_email": "För faktureringsfrågor, vänligen kontakta oss direkt.", + "account_upgrade_dialog_billing_contact_website": "För frågor om fakturering hänvisar vi till vår webbplats.", + "account_upgrade_dialog_button_redirect_signup": "Registrera dig nu", + "account_upgrade_dialog_button_pay_now": "Betala nu och prenumerera", + "account_upgrade_dialog_button_cancel_subscription": "Avbryt prenumeration", + "account_upgrade_dialog_button_update_subscription": "Uppdatera prenumeration", + "account_tokens_table_label_header": "Etikett", + "account_tokens_table_last_access_header": "Sista åtkomst", + "account_tokens_table_expires_header": "Upphör", + "account_tokens_table_never_expires": "Upphör aldrig", + "account_tokens_table_current_session": "Nuvarande webbläsarsession", + "account_tokens_table_cannot_delete_or_edit": "Det går inte att redigera eller ta bort aktuell sessionstoken", + "account_tokens_table_last_origin_tooltip": "Från IP-adress {{ip}}, klicka för att söka upp", + "account_tokens_dialog_button_cancel": "Avbryt", + "account_tokens_dialog_expires_label": "Åtkomsttoken löper ut om", + "account_tokens_dialog_expires_unchanged": "Lämna utgångsdatumet oförändrat", + "account_tokens_dialog_expires_x_hours": "Token går ut om {{hours}} timmar", + "account_tokens_dialog_expires_x_days": "Token löper ut om {{days}} dagar", + "account_tokens_dialog_expires_never": "Token upphör aldrig att gälla", + "account_tokens_delete_dialog_title": "Ta bort åtkomsttoken", + "account_tokens_delete_dialog_description": "Innan du tar bort en åtkomsttoken bör du se till att inga program eller skript använder den aktivt. Den här åtgärden kan inte ångras.", + "prefs_notifications_title": "Notifieringar", + "prefs_notifications_sound_title": "Ljud för meddelanden", + "prefs_notifications_sound_description_none": "Meddelanden spelar inte upp något ljud när de kommer", + "prefs_notifications_sound_play": "Spela upp valt ljud", + "prefs_notifications_min_priority_title": "Lägsta prioritet", + "prefs_notifications_min_priority_description_any": "Visa alla meddelanden, oavsett prioritet", + "prefs_notifications_min_priority_description_x_or_higher": "Visa meddelanden om prioritet är {{number}} ({{name}}) eller högre", + "prefs_notifications_min_priority_description_max": "Visa notifieringar om prioritet är 5 (max)", + "prefs_notifications_min_priority_default_and_higher": "Standardprioritet och högre", + "prefs_notifications_min_priority_high_and_higher": "Hög prioritet och högre", + "prefs_notifications_min_priority_max_only": "Bara högsta prioritet", + "prefs_notifications_delete_after_title": "Radera meddelanden", + "prefs_notifications_delete_after_one_week": "Efter en vecka", + "prefs_notifications_delete_after_one_month": "Efter en månad", + "prefs_notifications_delete_after_never_description": "Meddelanden raderas aldrig automatiskt", + "prefs_notifications_delete_after_three_hours_description": "Meddelanden raderas automatiskt efter tre timmar", + "prefs_users_description": "Lägg till/ta bort användare för dina skyddade ämnen här. Observera att användarnamn och lösenord lagras i webbläsarens lokala lagring.", + "prefs_users_delete_button": "Ta bort användare", + "prefs_users_table_cannot_delete_or_edit": "Kan inte ta bort eller redigera inloggad användare", + "prefs_users_table_user_header": "Användare", + "prefs_users_table_base_url_header": "Service-URL", + "prefs_users_dialog_username_label": "Användarnamn, t.ex. phil", + "prefs_reservations_title": "Reserverade ämnen", + "prefs_reservations_description": "Du kan reservera ämnesnamn för personligt bruk här. Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.", + "prefs_reservations_limit_reached": "Du har nått gränsen för reserverade ämnen.", + "prefs_reservations_add_button": "Lägg till reserverat ämne", + "prefs_reservations_dialog_title_edit": "Redigera reserverat ämne", + "prefs_reservations_dialog_title_delete": "Ta bort ämnesreservation", + "signup_error_creation_limit_reached": "Gränsen för skapande av konton har uppnåtts", + "alert_not_supported_context_description": "Meddelanden stöds endast via HTTPS. Detta är en begränsning av Notifications API.", + "notifications_actions_not_supported": "Åtgärd stöds inte i webbapplikationen", + "notifications_none_for_any_description": "För att skicka meddelanden till ett ämne är det bara att PUT eller POST till ämnets URL. Här är ett exempel med ett av dina ämnen.", + "notifications_no_subscriptions_description": "Klicka på länken \"{{linktext}}\" för att skapa eller prenumerera på ett ämne. Därefter kan du skicka meddelanden via PUT eller POST och du får meddelanden här.", + "display_name_dialog_title": "Ändra visningsnamn", + "display_name_dialog_description": "Ange ett alternativt namn för ett ämne som visas i prenumerationslistan. På så sätt kan du lättare identifiera ämnen med komplicerade namn.", + "display_name_dialog_placeholder": "Visningsnamn", + "reserve_dialog_checkbox_label": "Reservera ämne och konfigurera åtkomst", + "publish_dialog_title_no_topic": "Publicera meddelande", + "publish_dialog_progress_uploading_detail": "Laddar upp {{loaded}}/{{{total}} ({{procent}}}%) …", + "publish_dialog_priority_min": "Lägsta prioritet", + "publish_dialog_priority_low": "Låg prioritet", + "publish_dialog_priority_default": "Standard prioritet", + "publish_dialog_priority_high": "Hög prioritet", + "publish_dialog_priority_max": "Max. prioritet", + "publish_dialog_base_url_label": "Service-URL", + "publish_dialog_email_label": "E-post", + "publish_dialog_attach_reset": "Ta bort URL för bifogade filer", + "publish_dialog_chip_email_label": "Vidarebefordra till e-post", + "publish_dialog_chip_attach_url_label": "Bifoga fil via URL", + "publish_dialog_chip_attach_file_label": "Bifoga lokal fil", + "publish_dialog_chip_delay_label": "Fördröj leveransen", + "publish_dialog_chip_topic_label": "Ändra ämne", + "publish_dialog_button_cancel_sending": "Avbryt sändning", + "publish_dialog_button_cancel": "Avbryt", + "publish_dialog_attached_file_remove": "Ta bort bifogad fil", + "publish_dialog_drop_file_here": "Släpp filen här", + "emoji_picker_search_clear": "Rensa sökning", + "subscribe_dialog_subscribe_title": "Prenumerera på ämnet", + "subscribe_dialog_subscribe_description": "Ämnen kanske inte är lösenordsskyddade, så välj ett namn som inte är lätt att gissa. När du har prenumererat kan du lägga in/lägga in meddelanden.", + "subscribe_dialog_subscribe_topic_placeholder": "Ämnesnamn, t.ex. phils_alerts", + "subscribe_dialog_subscribe_use_another_label": "Använd en annan server", + "subscribe_dialog_subscribe_base_url_label": "Service-URL", + "subscribe_dialog_subscribe_button_generate_topic_name": "Generera namn", + "subscribe_dialog_subscribe_button_subscribe": "Prenumerera", + "subscribe_dialog_login_title": "Inloggning krävs", + "subscribe_dialog_login_description": "Det här ämnet är lösenordsskyddat. Ange användarnamn och lösenord för att prenumerera.", + "subscribe_dialog_login_username_label": "Användarnamn, t.ex. phil", + "subscribe_dialog_login_password_label": "Lösenord", + "subscribe_dialog_login_button_login": "Logga in", + "subscribe_dialog_error_user_not_authorized": "Användaren {{användarnamn}} inte auktoriserad", + "subscribe_dialog_error_topic_already_reserved": "Ämnet är redan reserverat", + "account_basics_title": "Konto", + "account_basics_tier_paid_until": "Prenumerationen är betald fram till {{datum}}, och kommer att förnyas automatiskt", + "account_basics_username_title": "Användarnamn", + "account_basics_username_description": "Hej, det är du ❤", + "account_basics_username_admin_tooltip": "Du är admin", + "account_basics_password_title": "Lösenord", + "account_basics_password_description": "Ändra lösenordet till ditt konto", + "account_basics_tier_payment_overdue": "Din betalning är försenad. Vänligen uppdatera din betalningsmetod, annars kommer ditt konto att nedgraderas inom kort.", + "account_basics_password_dialog_title": "Byt lösenord", + "account_basics_password_dialog_current_password_label": "Aktuellt lösenord", + "account_basics_password_dialog_new_password_label": "Nytt lösenord", + "account_basics_password_dialog_button_submit": "Byt lösenord", + "account_basics_password_dialog_current_password_incorrect": "Felaktigt lösenord", + "account_usage_title": "Användning", + "account_usage_of_limit": "av {{limit}}", + "account_usage_unlimited": "Obegränsad", + "account_usage_limits_reset_daily": "Användningsgränserna återställs dagligen vid midnatt (UTC)", + "account_basics_tier_title": "Kontotyp", + "account_basics_tier_description": "Ditt kontos nivå", + "account_basics_tier_admin": "Admin", + "account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} nivå)", + "account_basics_tier_admin_suffix_no_tier": "(ingen nivå)", + "account_basics_tier_basic": "Grundläggande", + "account_basics_tier_upgrade_button": "Uppgradera till Pro", + "account_basics_tier_change_button": "Ändra", + "account_usage_cannot_create_portal_session": "Det går inte att öppna faktureringsportalen", + "account_usage_basis_ip_description": "Användningsstatistik och begränsningar för det här kontot baseras på din IP-adress, så de kan delas med andra användare. De gränser som visas ovan är ungefärliga och baseras på befintliga gränser.", + "account_tokens_title": "Åtkomsttoken", + "prefs_notifications_delete_after_one_day_description": "Meddelanden raderas automatiskt efter en dag", + "prefs_notifications_delete_after_one_week_description": "Meddelanden raderas automatiskt efter en vecka", + "prefs_notifications_delete_after_one_month_description": "Meddelanden raderas automatiskt efter en månad", + "prefs_users_title": "Hantera användare", + "prefs_reservations_table_not_subscribed": "Prenumererar inte", + "prefs_reservations_table_click_to_subscribe": "Klicka för att prenumerera", + "prefs_reservations_edit_button": "Redigera ämnesåtkomst", + "prefs_reservations_delete_button": "Återställ ämnesåtkomst", + "prefs_reservations_table": "Tabell över reserverade ämnen", + "prefs_reservations_table_topic_header": "Ämne", + "prefs_reservations_table_access_header": "Tillgång", + "prefs_reservations_table_everyone_deny_all": "Endast jag kan publicera och prenumerera", + "prefs_reservations_table_everyone_read_only": "Jag kan publicera och prenumerera, alla kan prenumerera", + "prefs_reservations_table_everyone_write_only": "Jag kan publicera och prenumerera, alla kan publicera", + "prefs_reservations_table_everyone_read_write": "Alla kan publicera och prenumerera", + "prefs_reservations_dialog_title_add": "Reserverade ämnen", + "prefs_reservations_dialog_description": "Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.", + "prefs_reservations_dialog_topic_label": "Ämne", + "prefs_reservations_dialog_access_label": "Tillgång", + "reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor", + "reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.", + "reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet", + "reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.", + "publish_dialog_call_label": "Telefonsamtal", + "publish_dialog_call_reset": "Ta bort telefonsamtal", + "publish_dialog_chip_call_label": "Telefonsamtal", + "account_basics_phone_numbers_title": "Telefonnummer", + "account_basics_phone_numbers_description": "För notifieringar via telefonsamtal", + "account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp", + "account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer", + "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", + "account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Ring mig", + "account_basics_phone_numbers_dialog_code_label": "Verifieringskod", + "account_basics_phone_numbers_dialog_channel_call": "Ring", + "account_usage_calls_title": "Telefonsamtal som gjorts", + "account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto", + "publish_dialog_call_item": "Ring telefonnummer {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer", + "account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.", + "account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal", + "account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal" +} diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index adbf467c..28eca9f6 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -34,7 +34,7 @@ "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.", "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_password_label": "Parola", - "subscribe_dialog_login_button_back": "Geri", + "common_back": "Geri", "subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_anonymous": "anonim", @@ -44,7 +44,7 @@ "prefs_notifications_min_priority_title": "En düşük öncelik", "prefs_notifications_min_priority_any": "Herhangi bir öncelik", "publish_dialog_topic_placeholder": "Konu adı, örn. benim_uyarilarim", - "alert_grant_button": "Şimdi ver", + "alert_notification_permission_required_button": "Şimdi ver", "alert_not_supported_title": "Bildirimler desteklenmiyor", "notifications_attachment_link_expires": "bağlantının süresi {{date}} tarihinde doluyor", "notifications_click_copy_url_title": "Bağlantı URL'sini panoya kopyala", @@ -59,8 +59,8 @@ "notifications_attachment_open_button": "Eki aç", "nav_button_documentation": "Belgelendirme", "nav_button_publish_message": "Bildirim yayınla", - "alert_grant_title": "Bildirimler devre dışı", - "alert_grant_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.", + "alert_notification_permission_required_title": "Bildirimler devre dışı", + "alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.", "alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor.", "notifications_copied_to_clipboard": "Panoya kopyalandı", "notifications_tags": "Etiketler", @@ -77,7 +77,7 @@ "notifications_example": "Örnek", "notifications_more_details": "Daha fazla bilgi için web sitesine veya belgelendirmeye bakın.", "publish_dialog_chip_attach_url_label": "URL ile dosya ekle", - "prefs_notifications_min_priority_default_and_higher": "Öntanımlı öncelik ve üstü", + "prefs_notifications_min_priority_default_and_higher": "Varsayılan öncelik ve üstü", "prefs_notifications_delete_after_three_hours": "Üç saat sonra", "notifications_none_for_any_description": "Bir konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir. İşte konularınızdan birini kullanan bir örnek.", "notifications_no_subscriptions_title": "Henüz aboneliğiniz yok gibi görünüyor.", @@ -126,9 +126,9 @@ "prefs_users_dialog_username_label": "Kullanıcı adı, örn. phil", "prefs_users_table_user_header": "Kullanıcı", "prefs_users_dialog_password_label": "Parola", - "prefs_users_dialog_button_add": "Ekle", - "prefs_users_dialog_button_cancel": "İptal", - "prefs_users_dialog_button_save": "Kaydet", + "common_add": "Ekle", + "common_cancel": "İptal", + "common_save": "Kaydet", "prefs_appearance_title": "Görünüm", "prefs_appearance_language_title": "Dil", "error_boundary_title": "Olamaz, ntfy çöktü", @@ -152,5 +152,233 @@ "prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez", "priority_high": "yüksek", "notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor", - "notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder" + "notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder", + "action_bar_show_menu": "Menüyü göster", + "action_bar_logo_alt": "ntfy logosu", + "action_bar_toggle_action_menu": "Eylem menüsünü aç/kapat", + "message_bar_show_dialog": "Yayınla iletişim kutusunu göster", + "message_bar_publish": "Mesaj yayınla", + "nav_button_connecting": "bağlanıyor", + "notifications_list": "Bildirimler listesi", + "notifications_list_item": "Bildirim", + "notifications_delete": "Sil", + "notifications_attachment_image": "Ek resmi", + "notifications_attachment_file_image": "resim dosyası", + "notifications_attachment_file_video": "video dosyası", + "notifications_attachment_file_audio": "ses dosyası", + "notifications_attachment_file_app": "Android uygulama dosyası", + "notifications_attachment_file_document": "diğer belge", + "publish_dialog_emoji_picker_show": "Emoji seç", + "publish_dialog_topic_reset": "Konuyu sıfırla", + "publish_dialog_attach_reset": "Ek URL'sini kaldır", + "publish_dialog_delay_reset": "Gecikmeli teslimatı kaldır", + "publish_dialog_attached_file_remove": "Ekli dosyayı kaldır", + "emoji_picker_search_clear": "Aramayı temizle", + "subscribe_dialog_subscribe_base_url_label": "Hizmet URL'si", + "prefs_notifications_sound_play": "Seçilen sesi çal", + "error_boundary_unsupported_indexeddb_description": "ntfy web uygulamasının çalışması için IndexedDB'ye ihtiyacı var ve tarayıcınız gizli tarama modunda IndexedDB'yi desteklemiyor.

Bu talihsiz olsa da, ntfy web uygulamasını gizli tarama modunda kullanmak pek mantıklı değildir, çünkü her şey tarayıcı deposunda saklanır. Bu GitHub sorununda bununla ilgili daha fazla bilgi edinebilir veya Discord veya Matrix üzerinden bizimle konuşabilirsiniz.", + "notifications_new_indicator": "Yeni bildirim", + "action_bar_toggle_mute": "Bildirimleri sesini kapat/aç", + "publish_dialog_click_reset": "Tıklama URL'sini kaldır", + "prefs_users_table": "Kullanıcılar tablosu", + "error_boundary_unsupported_indexeddb_title": "Gizli tarama desteklenmiyor", + "nav_button_muted": "Bildirimler sessize alındı", + "notifications_mark_read": "Okundu olarak işaretle", + "notifications_priority_x": "Öncelik {{priority}}", + "publish_dialog_email_reset": "E-posta yönlendirmesini kaldır", + "prefs_users_edit_button": "Kullanıcıyı düzenle", + "prefs_users_delete_button": "Kullanıcı sil", + "signup_form_confirm_password": "Parolayı doğrula", + "signup_form_button_submit": "Kaydol", + "signup_form_toggle_password_visibility": "Parola görünürlüğünü değiştir", + "signup_already_have_account": "Zaten hesabınız var mı? Oturum açın!", + "signup_disabled": "Kayıt devre dışı bırakıldı", + "signup_error_username_taken": "{{username}} kullanıcı adı zaten alındı", + "signup_error_creation_limit_reached": "Hesap oluşturma sınırına ulaşıldı", + "login_title": "ntfy hesabınızda oturum açın", + "login_form_button_submit": "Oturum aç", + "login_link_signup": "Kaydol", + "login_disabled": "Oturum açma devre dışı bırakıldı", + "action_bar_account": "Hesap", + "action_bar_change_display_name": "Görünen adı değiştir", + "action_bar_reservation_add": "Konuyu ayırt", + "action_bar_reservation_edit": "Ayırtmayı değiştir", + "action_bar_reservation_delete": "Ayırtmayı kaldır", + "action_bar_reservation_limit_reached": "Sınıra ulaşıldı", + "action_bar_sign_in": "Oturum aç", + "action_bar_sign_up": "Kaydol", + "nav_button_account": "Hesap", + "nav_upgrade_banner_label": "ntfy Pro'ya yükselt", + "alert_not_supported_context_description": "Bildirimler yalnızca HTTPS üzerinden desteklenir. Bu, Bildirim API'sinin bir sınırlamasıdır.", + "display_name_dialog_description": "Abonelik listesinde görüntülenen bir konu için farklı bir ad belirleyin. Bu, karmaşık adlara sahip konuların daha kolay tanınmasına yardımcı olur.", + "display_name_dialog_placeholder": "Görünen ad", + "reserve_dialog_checkbox_label": "Konuyu ayırt ve erişimi yapılandır", + "subscribe_dialog_error_topic_already_reserved": "Konu zaten ayırtıldı", + "account_basics_title": "Hesap", + "account_basics_username_title": "Kullanıcı adı", + "account_basics_username_description": "Hey, bu sizsiniz ❤", + "account_basics_username_admin_tooltip": "Siz Yöneticisiniz", + "account_basics_password_title": "Parola", + "account_basics_password_description": "Hesap parolanızı değiştirin", + "account_basics_password_dialog_current_password_label": "Geçerli parola", + "account_basics_password_dialog_title": "Parolayı değiştir", + "account_basics_password_dialog_button_submit": "Parolayı değiştir", + "account_basics_password_dialog_current_password_incorrect": "Parola yanlış", + "account_usage_title": "Kullanım", + "account_usage_of_limit": "/ {{limit}}", + "account_usage_unlimited": "Sınırsız", + "account_usage_limits_reset_daily": "Kullanım sınırları her gün gece yarısında (UTC) sıfırlanır", + "account_basics_tier_title": "Hesap türü", + "account_basics_tier_description": "Hesabınızın güç seviyesi", + "account_basics_tier_admin": "Yönetici", + "account_basics_tier_basic": "Temel", + "account_basics_tier_free": "Ücretsiz", + "account_basics_tier_upgrade_button": "Pro'ya yükselt", + "account_basics_tier_change_button": "Değiştir", + "account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek", + "account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)", + "account_basics_tier_admin_suffix_no_tier": "(seviye yok)", + "account_basics_tier_manage_billing_button": "Faturalandırmayı yönet", + "account_usage_reservations_title": "Ayırtılan konular", + "account_usage_reservations_none": "Bu hesap için ayırtılan konu yok", + "account_usage_attachment_storage_title": "Ek depolama", + "account_usage_attachment_storage_description": "Dosya başına {{filesize}}, {{expiry}} sonrasında silinir", + "account_usage_cannot_create_portal_session": "Faturalandırma sayfası açılamıyor", + "account_delete_title": "Hesabı sil", + "account_delete_description": "Hesabınızı kalıcı olarak silin", + "account_delete_dialog_description": "Bu işlem, sunucuda depolanan tüm veriler dahil olmak üzere hesabınızı kalıcı olarak silecektir. Silme işleminden sonra kullanıcı adınız 7 gün boyunca kullanılamayacaktır. Gerçekten devam etmek istiyorsanız, lütfen aşağıdaki kutuya parolanızı yazarak onaylayın.", + "account_delete_dialog_button_cancel": "İptal", + "account_delete_dialog_button_submit": "Hesabı kalıcı olarak sil", + "account_delete_dialog_billing_warning": "Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.", + "account_upgrade_dialog_title": "Hesap seviyesini değiştir", + "account_upgrade_dialog_proration_info": "Fiyatlandırma: Ücretli planlar arasında yükseltme yaparken, fiyat farkı hemen tahsil edilecektir. Daha düşük bir seviyeye inildiğinde, bakiye gelecek faturalandırma dönemleri için ödeme yapmak üzere kullanılacaktır.", + "account_upgrade_dialog_reservations_warning_other": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce lütfen en az {{count}} ayırtmayı silin. Ayırtmaları Ayarlar sayfasından kaldırabilirsiniz.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} konu ayırtıldı", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} günlük mesaj", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} günlük e-posta", + "account_upgrade_dialog_tier_features_attachment_file_size": "dosya başına {{filesize}}", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} toplam depolama", + "account_upgrade_dialog_tier_selected_label": "Seçilen", + "account_upgrade_dialog_tier_current_label": "Geçerli", + "account_upgrade_dialog_button_cancel": "İptal", + "account_upgrade_dialog_button_redirect_signup": "Şimdi kaydol", + "account_upgrade_dialog_button_pay_now": "Şimdi öde ve abone ol", + "account_upgrade_dialog_button_cancel_subscription": "Aboneliği iptal et", + "account_tokens_title": "Erişim belirteçleri", + "account_tokens_table_token_header": "Belirteç", + "account_tokens_table_label_header": "Etiket", + "account_tokens_table_current_session": "Geçerli tarayıcı oturumu", + "common_copy_to_clipboard": "Panoya kopyala", + "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı", + "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", + "account_tokens_table_create_token_button": "Erişim belirteci oluştur", + "account_tokens_table_last_origin_tooltip": "{{ip}} IP adresinden, aramak için tıklayın", + "account_tokens_dialog_title_edit": "Erişim belirtecini düzenle", + "account_tokens_table_expires_header": "Süre dolumu", + "account_tokens_table_never_expires": "Asla süresi dolmaz", + "account_tokens_dialog_title_delete": "Erişim belirtecini sil", + "account_tokens_dialog_label": "Etiket, örn. Radarr bildirimleri", + "account_tokens_dialog_button_create": "Belirteç oluştur", + "account_tokens_dialog_button_update": "Belirteci güncelle", + "account_tokens_dialog_button_cancel": "İptal", + "account_tokens_dialog_expires_label": "Erişim belirtecinin süre dolumu", + "account_tokens_dialog_expires_unchanged": "Süre dolumu tarihini değiştirmeden bırak", + "account_tokens_dialog_expires_x_hours": "Belirtecin süresi {{hours}} saat içinde dolacak", + "account_tokens_dialog_expires_x_days": "Belirtecin süresi {{days}} gün içinde dolacak", + "account_tokens_dialog_expires_never": "Belirtecin süresi asla dolmaz", + "account_tokens_delete_dialog_title": "Erişim belirtecini sil", + "account_tokens_delete_dialog_description": "Bir erişim belirtecini silmeden önce, hiçbir uygulamanın veya betiğin onu etkin olarak kullanmadığından emin olun. Bu işlem geri alınamaz.", + "account_tokens_delete_dialog_submit_button": "Belirteci kalıcı olarak sil", + "prefs_users_table_cannot_delete_or_edit": "Oturum açan kullanıcı silinemez veya düzenlenemez", + "prefs_reservations_title": "Ayırtılan konular", + "prefs_reservations_description": "Konu adlarını burada kişisel kullanım için ayırtabilirsiniz. Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.", + "prefs_reservations_limit_reached": "Ayırtılan konu sınırınıza ulaştınız.", + "prefs_reservations_edit_button": "Konu erişimini düzenle", + "prefs_reservations_table": "Ayırtılan konular tablosu", + "prefs_reservations_table_topic_header": "Konu", + "prefs_reservations_table_access_header": "Erişim", + "prefs_reservations_table_everyone_deny_all": "Yalnızca ben yayınlayabilir ve abone olabilirim", + "prefs_reservations_table_everyone_write_only": "Ben yayınlayabilir ve abone olabilirim, herkes yayınlayabilir", + "prefs_reservations_table_click_to_subscribe": "Abone olmak için tıklayın", + "prefs_reservations_dialog_title_add": "Konuyu ayırt", + "prefs_reservations_dialog_title_edit": "Ayırtılan konuyu düzenle", + "prefs_reservations_dialog_title_delete": "Konu ayırtmasını sil", + "prefs_reservations_dialog_description": "Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.", + "prefs_reservations_dialog_topic_label": "Konu", + "prefs_reservations_dialog_access_label": "Erişim", + "reservation_delete_dialog_action_keep_title": "Önbelleğe alınan mesajları ve ekleri sakla", + "reservation_delete_dialog_action_keep_description": "Sunucuda önbelleğe alınan mesajlar ve ekler, konu adını bilen kişiler için görülebilir hale gelecektir.", + "reservation_delete_dialog_action_delete_title": "Önbelleğe alınan mesajları ve ekleri sil", + "reservation_delete_dialog_action_delete_description": "Önbelleğe alınan mesajlar ve ekler kalıcı olarak silinecektir. Bu işlem geri alınamaz.", + "reservation_delete_dialog_submit_button": "Ayırtmayı sil", + "signup_title": "ntfy hesabı oluştur", + "signup_form_username": "Kullanıcı adı", + "signup_form_password": "Parola", + "action_bar_profile_title": "Profil", + "action_bar_profile_logout": "Oturumu kapat", + "action_bar_profile_settings": "Ayarlar", + "nav_upgrade_banner_description": "Konuları ayırtma, daha fazla mesaj ve e-posta, daha büyük ekler", + "display_name_dialog_title": "Görünen adı değiştir", + "account_basics_password_dialog_new_password_label": "Yeni parola", + "account_usage_basis_ip_description": "Bu hesabın kullanım istatistikleri ve sınırları IP adresinize dayalıdır, bu nedenle diğer kullanıcılarla paylaşılabilir. Yukarıda gösterilen sınırlar, mevcut hız sınırlarına dayalı olarak yaklaşık değerlerdir.", + "subscribe_dialog_subscribe_button_generate_topic_name": "Ad oluştur", + "account_basics_password_dialog_confirm_password_label": "Parolayı doğrula", + "account_basics_tier_payment_overdue": "Ödemenizin vadesi geçti. Lütfen ödeme yönteminizi güncelleyin, aksi takdirde hesabınızın seviyesi yakında düşürülecektir.", + "account_usage_messages_title": "Yayınlanan mesajlar", + "account_basics_tier_canceled_subscription": "Aboneliğiniz iptal edildi ve {{date}} tarihinde ücretsiz hesap seviyesine düşürülecek.", + "account_usage_emails_title": "Gönderilen e-postalar", + "account_upgrade_dialog_cancel_warning": "Bu, {{date}} tarihinde aboneliğinizi iptal edecek ve hesabınızın seviyesini düşürecektir. Bu tarihte, sunucuda önbelleğe alınan mesajlar ve ayırtılan konular silinecektir.", + "account_delete_dialog_label": "Parola", + "prefs_users_description_no_sync": "Kullanıcılar ve parolalar hesabınızla eşzamanlanmıyor.", + "account_upgrade_dialog_reservations_warning_one": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce lütfen en az bir ayırtmayı silin. Ayırtmaları Ayarlar sayfasından kaldırabilirsiniz.", + "account_tokens_dialog_title_create": "Erişim belirteci oluştur", + "account_tokens_description": "ntfy API aracılığıyla yayınlarken ve abone olurken erişim belirteçlerini kullanın, böylece hesap kimlik bilgilerinizi göndermek zorunda kalmazsınız. Daha fazla bilgi edinmek için belgelere bakın.", + "account_upgrade_dialog_button_update_subscription": "Aboneliği güncelle", + "account_tokens_table_last_access_header": "Son erişim", + "prefs_reservations_add_button": "Ayırtılan konu ekle", + "prefs_reservations_delete_button": "Konu erişimini sıfırla", + "prefs_reservations_table_everyone_read_only": "Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir", + "prefs_reservations_table_not_subscribed": "Abone olunmadı", + "prefs_reservations_table_everyone_read_write": "Herkes yayınlayabilir ve abone olabilir", + "reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz.", + "account_basics_tier_interval_yearly": "yıllık", + "account_upgrade_dialog_tier_features_no_reservations": "Ayırtılan konu yok", + "account_upgrade_dialog_tier_price_billed_monthly": "Yıllık {{price}}. Aylık faturalandırılır.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} yıllık olarak faturalandırılır. {{save}} tasarruf edin.", + "account_upgrade_dialog_interval_yearly": "Yıllık", + "account_upgrade_dialog_interval_yearly_discount_save": "%{{discount}} tasarruf edin", + "account_upgrade_dialog_tier_price_per_month": "ay", + "account_upgrade_dialog_billing_contact_email": "Faturalama ile ilgili sorularınız için lütfen doğrudan bizimle iletişime geçin.", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "%{{discount}} kadar tasarruf edin", + "account_upgrade_dialog_interval_monthly": "Aylık", + "account_basics_tier_interval_monthly": "aylık", + "account_upgrade_dialog_billing_contact_website": "Faturalama ile ilgili sorularınız için lütfen web sitemizi ziyaret edin.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} ayırtılan konu", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} günlük e-posta", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} günlük mesaj", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} günlük telefon araması", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} günlük telefon araması", + "publish_dialog_call_label": "Telefon araması", + "publish_dialog_call_reset": "Telefon aramasını kaldır", + "publish_dialog_chip_call_label": "Telefon araması", + "account_basics_phone_numbers_title": "Telefon numaraları", + "account_basics_phone_numbers_dialog_description": "Arama bildirimi özelliğini kullanmak için en az bir telefon numarası eklemeniz ve doğrulamanız gerekir. Doğrulama SMS veya telefon araması yoluyla yapılabilir.", + "account_basics_phone_numbers_description": "Telefon araması bildirimleri için", + "account_basics_phone_numbers_no_phone_numbers_yet": "Henüz telefon numarası yok", + "account_basics_phone_numbers_copied_to_clipboard": "Telefon numarası panoya kopyalandı", + "account_basics_phone_numbers_dialog_title": "Telefon numarası ekle", + "account_basics_phone_numbers_dialog_number_label": "Telefon numarası", + "account_basics_phone_numbers_dialog_check_verification_button": "Kodu doğrula", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Ara", + "account_usage_calls_none": "Bu hesapla telefon araması yapılamaz", + "publish_dialog_call_item": "{{number}} telefon numarasını ara", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Doğrulanan telefon numarası yok", + "account_basics_phone_numbers_dialog_number_placeholder": "örn. +905554443322", + "account_basics_phone_numbers_dialog_verify_button_sms": "SMS gönder", + "account_basics_phone_numbers_dialog_verify_button_call": "Beni ara", + "account_basics_phone_numbers_dialog_code_label": "Doğrulama kodu", + "account_basics_phone_numbers_dialog_code_placeholder": "örn. 123456", + "account_usage_calls_title": "Yapılan telefon aramaları", + "account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok" } diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json new file mode 100644 index 00000000..b09822dd --- /dev/null +++ b/web/public/static/langs/uk.json @@ -0,0 +1,385 @@ +{ + "action_bar_logo_alt": "логотип ntfy", + "action_bar_settings": "Налаштування", + "message_bar_type_message": "Введіть повідомлення тут", + "message_bar_error_publishing": "Помилка публікації сповіщення", + "message_bar_show_dialog": "Показати діалогове вікно публікації", + "nav_topics_title": "Підписки на теми", + "nav_button_settings": "Налаштування", + "nav_button_documentation": "Документація", + "nav_button_subscribe": "Підписатися на тему", + "nav_button_muted": "Сповіщення вимкнено", + "nav_button_connecting": "підключення", + "alert_notification_permission_required_title": "Сповіщення вимкнено", + "alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення.", + "alert_notification_permission_required_button": "Дозволити", + "alert_not_supported_title": "Сповіщення не підтримуються", + "notifications_list_item": "Сповіщення", + "notifications_attachment_image": "Прикріплене зображення", + "notifications_attachment_open_title": "Перейти на {{url}}", + "notifications_attachment_open_button": "Відкрити вкладення", + "notifications_attachment_link_expires": "термін дії посилання закінчується {{date}}", + "notifications_actions_http_request_title": "Надіслати HTTP {{method}} на {{url}}", + "notifications_none_for_any_title": "Ви не отримали жодних сповіщень.", + "notifications_no_subscriptions_description": "Натисніть \"{{linktext}}\" посилання, щоб створити або підписатися на тему. Після цього ви зможете надсилати повідомлення за допомогою PUT або POST, і ви отримуватимете тут повідомлення.", + "notifications_more_details": "Додаткову інформацію можна знайти на сайті або в документації.", + "notifications_loading": "Завантаження сповіщень…", + "publish_dialog_title_topic": "Опублікувати в {{topic}}", + "publish_dialog_title_no_topic": "Опублікувати сповіщення", + "publish_dialog_progress_uploading": "Завантаження…", + "publish_dialog_message_published": "Сповіщення опубліковано", + "publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}", + "publish_dialog_priority_low": "Низький пріоритет", + "publish_dialog_topic_label": "Назва теми", + "publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts", + "publish_dialog_topic_reset": "Скинути тему", + "publish_dialog_title_label": "Заголовок", + "publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад Сповіщення про дисковий простір", + "publish_dialog_message_label": "Повідомлення", + "publish_dialog_message_placeholder": "Введіть повідомлення", + "publish_dialog_tags_label": "Теги", + "publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад warning, srv1-backup", + "publish_dialog_click_placeholder": "URL-адреса, яка відкривається після натискання сповіщення", + "publish_dialog_email_label": "Електронна пошта", + "publish_dialog_attach_placeholder": "Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Видалити URL вкладення", + "publish_dialog_filename_placeholder": "Ім'я файлу вкладення", + "publish_dialog_delay_reset": "Видалити затримку доставлення", + "publish_dialog_chip_click_label": "Адреса", + "publish_dialog_chip_email_label": "Переслати на електронну пошту", + "publish_dialog_chip_topic_label": "Змінити тему", + "publish_dialog_attached_file_remove": "Видалити прикріплений файл", + "subscribe_dialog_subscribe_topic_placeholder": "Назва теми, наприклад phil_alerts", + "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", + "subscribe_dialog_subscribe_base_url_label": "URL служби", + "subscribe_dialog_login_password_label": "Пароль", + "common_back": "Назад", + "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", + "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", + "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", + "prefs_notifications_min_priority_description_any": "Показати всі сповіщень, незалежно від пріоритету", + "prefs_notifications_min_priority_any": "Будь-який пріоритет", + "prefs_notifications_min_priority_default_and_higher": "Пріоритет за замовчуванням та високий", + "prefs_notifications_delete_after_title": "Видалити сповіщення", + "prefs_notifications_delete_after_never": "Ніколи", + "prefs_notifications_delete_after_one_day": "Через день", + "prefs_notifications_delete_after_one_week": "Через тиждень", + "prefs_notifications_delete_after_one_month": "Через місяць", + "prefs_notifications_delete_after_never_description": "Сповіщення ніколи не видаляються автоматично", + "prefs_notifications_delete_after_three_hours_description": "Сповіщення автоматично видаляються через три години", + "prefs_notifications_delete_after_one_day_description": "Сповіщення автоматично видаляються через один день", + "prefs_notifications_delete_after_one_week_description": "Сповіщення автоматично видаляються через тиждень", + "prefs_notifications_delete_after_one_month_description": "Сповіщення автоматично видаляються через місяць", + "prefs_users_title": "Керувати користувачами", + "prefs_users_table": "Таблиця користувачів", + "prefs_users_edit_button": "Редагувати користувача", + "common_save": "Зберегти", + "prefs_appearance_title": "Зовнішній вигляд", + "priority_default": "за замовчуванням", + "priority_high": "високий", + "priority_max": "макс", + "error_boundary_title": "Ой, ntfy впав", + "error_boundary_button_copy_stack_trace": "Копіювати трасування стека", + "action_bar_show_menu": "Показати меню", + "action_bar_toggle_action_menu": "Відкрити/закрити меню", + "action_bar_send_test_notification": "Надіслати тестове сповіщення", + "action_bar_clear_notifications": "Очистити всі сповіщення", + "action_bar_toggle_mute": "Вимкнути/увімкнути сповіщення", + "action_bar_unsubscribe": "Відписатися", + "message_bar_publish": "Опублікувати повідомлення", + "nav_button_all_notifications": "Усі сповіщення", + "alert_not_supported_description": "Ваш браузер не підтримує сповіщення.", + "notifications_list": "Список сповіщень", + "notifications_mark_read": "Позначити як прочитане", + "notifications_delete": "Видалити", + "notifications_tags": "Теги", + "nav_button_publish_message": "Опублікувати сповіщення", + "notifications_attachment_copy_url_title": "Копіювати URL-адресу вкладення", + "notifications_attachment_link_expired": "термін дії посилання для завантаження закінчився", + "publish_dialog_progress_uploading_detail": "Завантажується {{loaded}}/{{total}} ({{percent}}%) …", + "notifications_priority_x": "Пріоритет {{priority}}", + "notifications_attachment_copy_url_button": "Копіювати URL-адресу", + "notifications_copied_to_clipboard": "Скопійовано в буфер обміну", + "notifications_attachment_file_video": "відео файл", + "notifications_attachment_file_audio": "звуковий файл", + "publish_dialog_emoji_picker_show": "Виберіть емодзі", + "notifications_new_indicator": "Нове сповіщення", + "notifications_attachment_file_image": "файл зображення", + "notifications_attachment_file_document": "інший документ", + "notifications_click_copy_url_title": "Копіювати URL-адресу посилання", + "notifications_click_copy_url_button": "Копіювати посилання", + "notifications_actions_not_supported": "Дія не підтримується у браузері", + "notifications_attachment_file_app": "Файл програми Android", + "notifications_click_open_button": "Відкрити посилання", + "notifications_actions_open_url_title": "Перейти на {{url}}", + "notifications_none_for_topic_description": "Щоб надіслати сповіщення до цієї теми, просто надішліть PUT або POST на URL-адресу цієї теми.", + "notifications_no_subscriptions_title": "Схоже, у вас ще немає жодної підписки.", + "publish_dialog_drop_file_here": "Перетягніть файл сюди", + "notifications_none_for_topic_title": "Ви ще не отримували сповіщення на цю тему.", + "notifications_example": "Приклад", + "notifications_none_for_any_description": "Щоб надіслати сповіщення до теми, просто надішліть PUT або POST на URL-адресу теми. Ось приклад, використовуючи одну з ваших тем.", + "publish_dialog_attachment_limits_file_and_quota_reached": "перевищує {{fileSizeLimit}} розмір файлу, {{remainingBytes}} залишилося", + "publish_dialog_priority_default": "Пріоритет за замовчуванням", + "publish_dialog_attachment_limits_file_reached": "перевищує {{fileSizeLimit}} розмір файлу", + "publish_dialog_priority_min": "Мін. пріоритет", + "publish_dialog_priority_high": "Високий пріоритет", + "publish_dialog_priority_max": "Макс. пріоритет", + "publish_dialog_base_url_placeholder": "URL-адреса сервісу, наприклад https://example.com", + "publish_dialog_base_url_label": "URL служби", + "publish_dialog_other_features": "Інші можливості:", + "publish_dialog_chip_attach_file_label": "Прикріпити локальний файл", + "publish_dialog_priority_label": "Пріоритет", + "publish_dialog_click_label": "Натисніть URL", + "publish_dialog_click_reset": "Видалити URL-адресу для натискання", + "publish_dialog_email_placeholder": "Адреса для пересилання сповіщення, наприклад phil@example.com", + "publish_dialog_attach_label": "URL-адреса вкладення", + "publish_dialog_filename_label": "Ім'я файлу", + "publish_dialog_delay_label": "Затримка", + "publish_dialog_email_reset": "Видалити пересилання електронної пошти", + "publish_dialog_chip_attach_url_label": "Прикріпити файл за URL", + "publish_dialog_details_examples_description": "Приклади та докладний опис усіх функцій, зверніться до документації.", + "publish_dialog_button_cancel_sending": "Скасувати відправку", + "publish_dialog_attached_file_filename_placeholder": "Ім'я прикріпленого файлу", + "publish_dialog_delay_placeholder": "Затримка доставлення, наприклад {{unixTimestamp}}, {{relativeTime}} або \"{{naturalLanguage}}\" (лише англійською)", + "publish_dialog_button_send": "Надіслати", + "publish_dialog_checkbox_publish_another": "Опублікувати ще", + "publish_dialog_chip_delay_label": "Затримка доставлення", + "publish_dialog_button_cancel": "Скасувати", + "publish_dialog_attached_file_title": "Прикріплений файл:", + "subscribe_dialog_subscribe_description": "Теми можуть не бути захищені паролем, тому виберіть назву, яку нелегко вгадати. Після підписки ви можете PUT/POST сповіщення.", + "emoji_picker_search_placeholder": "Пошук емодзі", + "emoji_picker_search_clear": "Очистити пошук", + "subscribe_dialog_subscribe_title": "Підпишіться на тему", + "subscribe_dialog_login_username_label": "Ім'я користувача, наприклад phil", + "prefs_notifications_title": "Сповіщення", + "subscribe_dialog_subscribe_button_cancel": "Скасувати", + "subscribe_dialog_subscribe_button_subscribe": "Підписатися", + "subscribe_dialog_error_user_anonymous": "анонімний", + "subscribe_dialog_login_title": "Потрібна авторизація", + "subscribe_dialog_login_description": "Ця тема захищена паролем. Будь ласка, введіть ім'я користувача та пароль, щоб підписатися.", + "prefs_notifications_sound_title": "Звук сповіщення", + "subscribe_dialog_login_button_login": "Логін", + "prefs_notifications_sound_no_sound": "Без звука", + "prefs_notifications_sound_play": "Відтворення вибраного звуку", + "prefs_users_description": "Додайте/видаляйте користувачів для захищених тем. Зверніть увагу, що ім'я користувача та пароль зберігаються у локальному сховищі браузера.", + "prefs_notifications_min_priority_title": "Мінімальний пріоритет", + "prefs_notifications_min_priority_high_and_higher": "Високий пріоритет і вище", + "prefs_notifications_min_priority_description_x_or_higher": "Показувати сповіщення, якщо пріоритет {{number}} ({{name}}) або вище", + "prefs_notifications_min_priority_description_max": "Показувати сповіщення, якщо пріоритет 5 (макс.)", + "prefs_notifications_min_priority_low_and_higher": "Низький та високий пріоритет", + "prefs_notifications_min_priority_max_only": "Тільки максимальний пріоритет", + "prefs_users_table_base_url_header": "URL служби", + "prefs_users_dialog_password_label": "Пароль", + "prefs_notifications_delete_after_three_hours": "Через три години", + "prefs_users_add_button": "Додати користувача", + "prefs_users_dialog_title_edit": "Редагувати користувача", + "prefs_users_dialog_base_url_label": "URL-адреса служби, наприклад https://ntfy.sh", + "prefs_users_delete_button": "Видалити користувача", + "prefs_users_table_user_header": "Користувач", + "prefs_users_dialog_title_add": "Додати користувача", + "prefs_users_dialog_username_label": "Ім'я користувача, наприклад phil", + "common_cancel": "Скасувати", + "common_add": "Додати", + "prefs_appearance_language_title": "Мова", + "error_boundary_gathering_info": "Зберіть більше інформації…", + "priority_min": "мін", + "error_boundary_description": "Очевидно, цього не повинно статися. Дуже шкода.
Якщо у вас є хвилина, повідомте про це на GitHub або повідомте нам через Discord або Matrix .", + "priority_low": "низький", + "error_boundary_stack_trace": "Трасування стека", + "error_boundary_unsupported_indexeddb_title": "Приватний перегляд не підтримується", + "error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.

На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це у цьому випуску GitHub або поспілкуватися з нами на Discord або Matrix.", + "signup_title": "Створення облікового запису ntfy", + "signup_form_username": "Ім'я користувача", + "signup_form_password": "Пароль", + "signup_form_confirm_password": "Підтвердіть пароль", + "signup_form_button_submit": "Зареєструватися", + "signup_form_toggle_password_visibility": "Перемкнути видимість пароля", + "signup_already_have_account": "Вже маєте обліковий запис? Увійдіть!", + "signup_disabled": "Реєстрацію вимкнено", + "signup_error_username_taken": "Ім'я користувача {{username}} вже зайнято", + "signup_error_creation_limit_reached": "Досягнуто обмеження на створення облікового запису", + "login_title": "Увійдіть до свого облікового запису ntfy", + "login_form_button_submit": "Увійти", + "login_link_signup": "Зареєструватися", + "login_disabled": "Вхід вимкнено", + "action_bar_account": "Обліковий запис", + "action_bar_reservation_add": "Зарезервувати тему", + "action_bar_reservation_edit": "Змінити резервування", + "action_bar_reservation_delete": "Видалити резервування", + "action_bar_reservation_limit_reached": "Досягнуто ліміту", + "action_bar_change_display_name": "Змінити відображувану назву", + "action_bar_profile_title": "Профіль", + "action_bar_profile_settings": "Налаштування", + "action_bar_sign_up": "Зареєструватися", + "nav_button_account": "Обліковий запис", + "nav_upgrade_banner_description": "Резервування тем, більше повідомлень та імейлів, більші вкладення", + "alert_not_supported_context_description": "Сповіщення підтримуються лише через HTTPS. Це обмеження Notifications API.", + "display_name_dialog_title": "Змінити відображувану назву", + "reserve_dialog_checkbox_label": "Зарезервувати тему та налаштувати доступ", + "subscribe_dialog_subscribe_button_generate_topic_name": "Згенерувати назву", + "subscribe_dialog_error_topic_already_reserved": "Тема вже зарезервована", + "account_basics_title": "Обліковий запис", + "account_basics_username_title": "Ім'я користувача", + "account_basics_username_description": "Привіт, це ти ❤", + "account_basics_password_dialog_title": "Змінити пароль", + "account_basics_password_dialog_current_password_label": "Поточний пароль", + "account_basics_password_dialog_new_password_label": "Новий пароль", + "account_basics_password_dialog_confirm_password_label": "Підтвердіть пароль", + "account_basics_password_dialog_button_submit": "Змінити пароль", + "account_basics_password_dialog_current_password_incorrect": "Неправильний пароль", + "account_usage_title": "Використання", + "account_usage_limits_reset_daily": "Ліміти використання скидаються щодня опівночі (UTC)", + "account_basics_tier_title": "Тип облікового запису", + "account_basics_tier_admin": "Адміністратор", + "action_bar_sign_in": "Увійти", + "action_bar_profile_logout": "Вийти", + "nav_upgrade_banner_label": "Оновлення до ntfy Pro", + "display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.", + "display_name_dialog_placeholder": "Відображуване ім'я", + "account_basics_password_title": "Пароль", + "account_basics_username_admin_tooltip": "Ви адміністратор", + "account_basics_tier_interval_monthly": "щомісяця", + "common_copy_to_clipboard": "Скопіювати в буфер обміну", + "account_basics_phone_numbers_title": "Номери телефонів", + "account_basics_phone_numbers_description": "Для сповіщень через телефонні дзвінки", + "account_basics_phone_numbers_no_phone_numbers_yet": "Поки що немає номерів телефонів", + "account_basics_phone_numbers_copied_to_clipboard": "Номер телефону скопійовано в буфер обміну", + "account_basics_phone_numbers_dialog_title": "Додати номер телефону", + "account_basics_phone_numbers_dialog_number_label": "Номер телефону", + "account_basics_phone_numbers_dialog_number_placeholder": "наприклад, +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Надіслати SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Зателефонуйте мені", + "account_basics_phone_numbers_dialog_code_label": "Код підтвердження", + "account_basics_phone_numbers_dialog_code_placeholder": "наприклад, 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Підтвердити код", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Дзвінок", + "account_basics_tier_interval_yearly": "щороку", + "account_usage_calls_title": "Здійснені телефонні дзвінки", + "account_usage_calls_none": "З цього облікового запису не можна здійснювати телефонні дзвінки", + "account_usage_attachment_storage_title": "Зберігання вкладень", + "account_usage_attachment_storage_description": "{{filesize}} на файл, видаляється після {{expiry}}", + "account_usage_basis_ip_description": "Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.", + "account_usage_cannot_create_portal_session": "Не вдається відкрити білінговий портал", + "account_delete_title": "Видалення облікового запису", + "account_delete_description": "Назавжди видалити свій обліковий запис", + "account_delete_dialog_label": "Пароль", + "account_delete_dialog_button_cancel": "Скасувати", + "account_delete_dialog_button_submit": "Видалити обліковий запис назавжди", + "account_delete_dialog_billing_warning": "Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.", + "account_upgrade_dialog_title": "Зміна рівня облікового запису", + "account_upgrade_dialog_interval_monthly": "Щомісяця", + "account_upgrade_dialog_interval_yearly": "Щорічно", + "account_upgrade_dialog_interval_yearly_discount_save": "економія {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "економія до {{discount}}%", + "publish_dialog_call_label": "Телефонний дзвінок", + "publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"", + "publish_dialog_chip_call_label": "Телефонний дзвінок", + "publish_dialog_call_reset": "Видалити телефонний дзвінок", + "account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.", + "account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.", + "account_basics_tier_upgrade_button": "Оновлення до Pro", + "account_basics_password_description": "Зміна пароля облікового запису", + "account_usage_of_limit": "з {{limit}}", + "account_usage_unlimited": "Без обмежень", + "account_basics_tier_description": "Рівень потужності вашого облікового запису", + "account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(без рівня)", + "account_basics_tier_basic": "Базовий", + "account_basics_tier_free": "Безкоштовний", + "account_basics_tier_change_button": "Змінити", + "account_basics_tier_paid_until": "Підписка оплачена до {{date}} і буде автоматично поновлюватися", + "account_basics_tier_payment_overdue": "Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.", + "account_basics_tier_canceled_subscription": "Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.", + "account_basics_tier_manage_billing_button": "Керувати рахунками", + "account_usage_messages_title": "Опубліковані повідомлення", + "account_usage_emails_title": "Надіслані електронні листи", + "account_usage_reservations_title": "Зарезервовані теми", + "account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище", + "account_upgrade_dialog_tier_current_label": "Поточний", + "account_upgrade_dialog_tier_selected_label": "Вибране", + "account_upgrade_dialog_cancel_warning": "Це скасує вашу підписку і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері , буде видалено.", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми", + "account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день", + "account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день", + "account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків", + "account_upgrade_dialog_tier_price_per_month": "місяць", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.", + "account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, зв’яжіться з нами безпосередньо.", + "account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш веб-сайт.", + "account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку", + "account_upgrade_dialog_button_update_subscription": "Оновити підписку", + "account_tokens_title": "Токени доступу", + "account_tokens_table_expires_header": "Термін дії закінчується", + "account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з документацією, щоб дізнатися більше.", + "account_tokens_table_token_header": "Токен", + "account_tokens_table_never_expires": "Ніколи не закінчується", + "account_tokens_table_label_header": "Мітка", + "account_tokens_table_current_session": "Поточний сеанс браузера", + "account_tokens_table_last_access_header": "Останній доступ", + "account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано", + "account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу", + "account_tokens_table_create_token_button": "Створити токен доступу", + "account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку", + "account_tokens_dialog_title_create": "Створити токен доступу", + "account_tokens_dialog_button_cancel": "Скасувати", + "account_tokens_dialog_title_edit": "Редагувати токен доступу", + "account_tokens_dialog_title_delete": "Видалити токен доступу", + "account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr", + "account_tokens_dialog_button_create": "Створити токен", + "account_tokens_dialog_button_update": "Оновити токен", + "account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через", + "account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин", + "account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів", + "account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. Ця дія не може бути скасована.", + "prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.", + "prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день", + "account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін", + "account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується", + "account_tokens_delete_dialog_title": "Видалити токен доступу", + "account_tokens_delete_dialog_submit_button": "Видалити токен назавжди", + "account_upgrade_dialog_proration_info": "Пропорція: При переході з одного тарифного плану на інший різниця в ціні буде списана негайно. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.", + "account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, будь ласка, видаліть принаймні одне резервування. Ви можете видалити резервування в Налаштуваннях.", + "account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, будь ласка, видаліть принаймні {{count}} резервувань. Ви можете видалити резервування в Налаштуваннях.", + "account_upgrade_dialog_button_cancel": "Скасувати", + "account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз", + "account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися", + "prefs_reservations_add_button": "Додати зарезервовану тему", + "prefs_reservations_edit_button": "Редагувати доступ до теми", + "prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.", + "prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися", + "prefs_reservations_table_topic_header": "Тема", + "prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.", + "prefs_reservations_table": "Таблиця зарезервованих тем", + "prefs_reservations_table_access_header": "Доступ", + "prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись", + "prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися", + "prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати", + "prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися", + "prefs_reservations_table_not_subscribed": "Не підписаний", + "prefs_reservations_dialog_title_add": "Зарезервувати тему", + "prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему", + "prefs_reservations_title": "Зарезервовані теми", + "prefs_reservations_delete_button": "Скинути доступ до теми", + "prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.", + "prefs_reservations_dialog_topic_label": "Тема", + "prefs_reservations_dialog_access_label": "Доступ", + "reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.", + "reservation_delete_dialog_submit_button": "Видалити резервування", + "publish_dialog_call_item": "Телефонувати за номером {{номер}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів", + "prefs_reservations_dialog_title_delete": "Видалити резервування теми", + "reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень", + "reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень", + "reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.", + "reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована." +} diff --git a/web/public/static/langs/vi.json b/web/public/static/langs/vi.json new file mode 100644 index 00000000..b2f94441 --- /dev/null +++ b/web/public/static/langs/vi.json @@ -0,0 +1,21 @@ +{ + "common_add": "Thêm", + "common_back": "Quay lại", + "signup_title": "Tạo tài khoản ntfy", + "signup_form_toggle_password_visibility": "Hiện mật khẩu", + "login_form_button_submit": "Đăng nhập", + "common_copy_to_clipboard": "Lưu vào clipboard", + "signup_form_username": "Tên user", + "signup_already_have_account": "Đã có tài khoản? Đăng nhập!", + "signup_disabled": "Đăng kí bị đóng", + "signup_error_username_taken": "Tên {{username}} đã được sử dụng", + "signup_error_creation_limit_reached": "Đã bị giới hạn tạo tài khoản", + "login_title": "Đăng nhập vào tài khoản ntfy", + "login_link_signup": "Đăng kí", + "login_disabled": "Đăng nhập bị đóng", + "action_bar_show_menu": "Hiện menu", + "signup_form_password": "Mật khẩu", + "action_bar_settings": "Cài đặt", + "signup_form_confirm_password": "Xác nhận mật khẩu", + "signup_form_button_submit": "Đăng kí" +} diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json new file mode 100644 index 00000000..e26e7f14 --- /dev/null +++ b/web/public/static/langs/zh_Hans.json @@ -0,0 +1,407 @@ +{ + "action_bar_show_menu": "显示菜单", + "action_bar_logo_alt": "ntfy图标", + "action_bar_mute_notifications": "静音", + "action_bar_settings": "设置", + "action_bar_send_test_notification": "发送测试通知", + "action_bar_clear_notifications": "清除所有通知", + "action_bar_unsubscribe": "取消订阅", + "action_bar_toggle_action_menu": "开启或关闭操作菜单", + "action_bar_unmute_notifications": "取消静音", + "message_bar_type_message": "在此处输入消息", + "message_bar_show_dialog": "显示发布对话框", + "message_bar_publish": "发布消息", + "nav_topics_title": "订阅主题", + "nav_button_all_notifications": "全部通知", + "nav_button_documentation": "文档", + "nav_button_publish_message": "发布通知", + "nav_button_subscribe": "订阅主题", + "nav_button_connecting": "正在连接", + "alert_notification_permission_required_title": "已禁用通知", + "alert_notification_permission_required_description": "授予浏览器显示桌面通知的权限。", + "alert_notification_permission_required_button": "现在授予", + "alert_not_supported_title": "不支持通知", + "alert_not_supported_description": "您的浏览器不支持通知。", + "alert_notification_ios_install_required_description": "要接收通知,请在iOS上点击分享图标,然后添加到主屏幕。", + "alert_notification_ios_install_required_title": "需要安装iOS应用程序", + "alert_notification_permission_denied_description": "你已禁用通知。要重新启用通知,请在浏览器设置中启用通知。", + "alert_notification_permission_denied_title": "已禁用通知", + "notifications_list": "通知列表", + "notifications_list_item": "通知", + "notifications_mark_read": "标记为已读", + "notifications_copied_to_clipboard": "复制到剪贴板", + "notifications_tags": "标记", + "notifications_priority_x": "优先级 {{priority}}", + "notifications_new_indicator": "新通知", + "notifications_attachment_open_button": "打开附件", + "notifications_attachment_link_expires": "链接过期 {{date}}", + "notifications_attachment_link_expired": "下载链接已过期", + "notifications_attachment_file_image": "图片文件", + "notifications_attachment_image": "附件图片", + "notifications_attachment_file_video": "视频文件", + "notifications_attachment_file_audio": "音频文件", + "notifications_attachment_file_app": "安卓应用文件", + "notifications_attachment_file_document": "其他文件", + "notifications_click_copy_url_title": "复制链接地址到剪贴板", + "notifications_click_copy_url_button": "复制链接", + "notifications_click_open_button": "打开链接", + "action_bar_toggle_mute": "暂停或恢复通知", + "nav_button_muted": "已暂停通知", + "notifications_actions_not_supported": "网页应用程序不支持操作", + "notifications_none_for_topic_title": "您尚未收到有关此主题的任何通知。", + "notifications_none_for_any_title": "您尚未收到任何通知。", + "notifications_none_for_any_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。", + "notifications_no_subscriptions_title": "看起来你还没有任何订阅。", + "notifications_example": "示例", + "notifications_more_details": "有关更多信息,请查看网站文档。", + "notifications_loading": "正在加载通知……", + "publish_dialog_title_topic": "发布到 {{topic}}", + "publish_dialog_title_no_topic": "发布通知", + "publish_dialog_progress_uploading": "正在上传……", + "publish_dialog_progress_uploading_detail": "正在上传 {{loaded}}/{{total}} ({{percent}}%) ……", + "publish_dialog_message_published": "已发布通知", + "publish_dialog_attachment_limits_file_and_quota_reached": "超过 {{fileSizeLimit}} 文件限制和配额,剩余 {{remainingBytes}}", + "publish_dialog_emoji_picker_show": "选择表情符号", + "publish_dialog_priority_min": "最低优先级", + "publish_dialog_priority_low": "低优先级", + "publish_dialog_priority_default": "默认优先级", + "publish_dialog_priority_high": "高优先级", + "publish_dialog_priority_max": "最高优先级", + "publish_dialog_topic_label": "主题名称", + "publish_dialog_topic_placeholder": "主题名称,例如 phil_alerts", + "publish_dialog_topic_reset": "重置主题", + "publish_dialog_title_label": "主题", + "publish_dialog_message_label": "消息", + "publish_dialog_message_placeholder": "在此输入消息", + "publish_dialog_tags_label": "标记", + "publish_dialog_priority_label": "优先级", + "publish_dialog_base_url_label": "服务链接地址", + "publish_dialog_base_url_placeholder": "服务链接地址,例如 https://example.com", + "publish_dialog_click_label": "点击链接地址", + "publish_dialog_click_placeholder": "点击通知时打开链接地址", + "publish_dialog_email_placeholder": "将通知转发到的地址,例如 phil@example.com", + "publish_dialog_email_reset": "移除电子邮件转发", + "publish_dialog_filename_label": "文件名", + "publish_dialog_filename_placeholder": "附件文件名", + "publish_dialog_delay_label": "延期", + "publish_dialog_other_features": "其它功能:", + "publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk", + "publish_dialog_delay_reset": "删除延期投递", + "publish_dialog_attach_reset": "移除附件链接地址", + "publish_dialog_chip_click_label": "点击链接地址", + "publish_dialog_chip_email_label": "转发邮件", + "publish_dialog_chip_attach_file_label": "本地文件附件", + "publish_dialog_chip_topic_label": "变更主题", + "publish_dialog_button_cancel_sending": "取消发送", + "publish_dialog_checkbox_publish_another": "发布另一个", + "publish_dialog_attached_file_title": "附件文件:", + "publish_dialog_attached_file_filename_placeholder": "附件文件名", + "publish_dialog_attached_file_remove": "删除附件文件", + "publish_dialog_drop_file_here": "将文件拖拽至此", + "emoji_picker_search_placeholder": "查找表情符号", + "emoji_picker_search_clear": "清除搜索", + "subscribe_dialog_subscribe_title": "订阅主题", + "publish_dialog_chip_delay_label": "延期投递", + "publish_dialog_chip_attach_url_label": "链接附件地址", + "subscribe_dialog_subscribe_use_another_label": "使用其他服务器", + "subscribe_dialog_subscribe_button_subscribe": "订阅", + "subscribe_dialog_login_title": "请登录", + "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", + "subscribe_dialog_login_username_label": "用户名,例如 phil", + "subscribe_dialog_login_password_label": "密码", + "common_back": "返回", + "subscribe_dialog_login_button_login": "登录", + "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", + "subscribe_dialog_error_user_anonymous": "匿名", + "prefs_notifications_title": "通知", + "prefs_notifications_sound_title": "通知提示音", + "prefs_notifications_sound_description_none": "收到通知时不播放任何声音", + "prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音", + "prefs_notifications_sound_no_sound": "静音", + "prefs_notifications_sound_play": "播放选中声音", + "prefs_notifications_min_priority_title": "最低优先级", + "prefs_notifications_min_priority_description_x_or_higher": "仅显示优先级为{{number}}({{name}})或以上的通知", + "prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知", + "prefs_notifications_min_priority_any": "任意优先级", + "prefs_notifications_min_priority_low_and_higher": "低优先级或更高", + "prefs_notifications_min_priority_default_and_higher": "默认优先级或更高", + "prefs_notifications_min_priority_high_and_higher": "高优先级或更高", + "prefs_notifications_min_priority_max_only": "仅最高优先级", + "prefs_notifications_delete_after_never": "从不", + "prefs_notifications_delete_after_one_month": "一月后", + "prefs_notifications_delete_after_one_week": "一周后", + "prefs_notifications_delete_after_never_description": "永不自动删除通知", + "prefs_notifications_delete_after_three_hours_description": "三小时后自动删除通知", + "prefs_notifications_delete_after_one_day_description": "一天后自动删除通知", + "prefs_notifications_delete_after_one_week_description": "一周后自动删除通知", + "prefs_notifications_delete_after_one_month_description": "一月后后自动删除通知", + "prefs_notifications_web_push_disabled": "已暂用", + "prefs_notifications_web_push_disabled_description": "当网页程序在运行时将会收到通知 (透过 WebSocket)", + "prefs_notifications_web_push_enabled": "已为 {{server}} 启用", + "prefs_notifications_web_push_enabled_description": "即使网页程序未有运行亦会收到通知 (via Web Push)", + "prefs_notifications_web_push_title": "背景通知", + "prefs_users_title": "管理用户", + "prefs_users_description": "在此处添加/删除受保护主题的用户。请注意,用户名和密码存储在浏览器的本地存储中。", + "prefs_users_add_button": "添加用户", + "prefs_users_dialog_title_add": "添加用户", + "prefs_users_dialog_title_edit": "编辑用户", + "prefs_users_dialog_username_label": "用户名,例如 phil", + "prefs_users_dialog_password_label": "密码", + "common_cancel": "取消", + "common_save": "保存", + "prefs_appearance_title": "外观", + "prefs_appearance_language_title": "语言", + "prefs_appearance_theme_title": "主題", + "prefs_appearance_theme_system": "系統 (預設)", + "prefs_appearance_theme_dark": "黑暗模式", + "prefs_appearance_theme_light": "光亮模式", + "priority_min": "最低", + "priority_low": "低", + "priority_default": "默认", + "priority_high": "高", + "priority_max": "最高", + "error_boundary_title": "天啊,ntfy 崩溃了", + "prefs_users_table_base_url_header": "服务链接地址", + "prefs_users_dialog_base_url_label": "服务链接地址,例如 https://ntfy.sh", + "error_boundary_button_copy_stack_trace": "复制堆栈跟踪", + "error_boundary_button_reload_ntfy": "重新加载 ntfy", + "error_boundary_stack_trace": "堆栈跟踪", + "error_boundary_gathering_info": "收集更多信息……", + "error_boundary_unsupported_indexeddb_title": "不支持隐私浏览", + "error_boundary_unsupported_indexeddb_description": "Ntfy Web应用程序需要IndexedDB才能运行,并且您的浏览器在私隐私浏览模式下不支持IndexedDB。

虽然这很不幸,但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义,因为所有东西都存储在浏览器存储中。您可以在本GitHub问题中阅读有关它的更多信息,或者在DiscordMatrix上与我们交谈。", + "message_bar_error_publishing": "发布通知时出错", + "nav_button_settings": "设置", + "notifications_delete": "删除", + "notifications_attachment_copy_url_title": "将附件中链接地址复制到剪贴板", + "notifications_attachment_copy_url_button": "复制链接地址", + "notifications_attachment_open_title": "转到 {{url}}", + "notifications_actions_http_request_title": "发送 HTTP {{method}} 到 {{url}}", + "notifications_actions_failed_notification": "通知失败", + "notifications_actions_open_url_title": "转到 {{url}}", + "notifications_none_for_topic_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。", + "subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts", + "notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。", + "publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制", + "publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警", + "publish_dialog_email_label": "电子邮件", + "publish_dialog_button_send": "发送", + "publish_dialog_checkbox_markdown": "格式化为 Markdown", + "publish_dialog_attachment_limits_quota_reached": "超过配额,剩余 {{remainingBytes}}", + "publish_dialog_attach_label": "附件链接地址", + "publish_dialog_click_reset": "移除点击连接地址", + "publish_dialog_button_cancel": "取消", + "subscribe_dialog_subscribe_button_cancel": "取消", + "subscribe_dialog_subscribe_base_url_label": "服务地址地址", + "subscribe_dialog_subscribe_use_another_background_info": "当网页程序未开启, 将不会收到来自其他服务器的通知", + "prefs_notifications_min_priority_description_any": "显示所有通知,无论优先级如何", + "prefs_notifications_delete_after_title": "删除通知", + "prefs_notifications_delete_after_three_hours": "三小时后", + "prefs_users_delete_button": "删除用户", + "prefs_users_table_user_header": "用户", + "common_add": "添加", + "prefs_notifications_delete_after_one_day": "一天后", + "error_boundary_description": "这显然不应该发生。对此非常抱歉。
如果您有时间,请在GitHub上报告,或通过DiscordMatrix告诉我们。", + "prefs_users_table": "用户表", + "prefs_users_edit_button": "编辑用户", + "publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup", + "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅文档。", + "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。", + "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)", + "account_usage_basis_ip_description": "此帐户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。", + "account_usage_cannot_create_portal_session": "无法打开计费门户", + "account_delete_title": "删除帐户", + "account_delete_description": "永久删除您的帐户", + "signup_error_username_taken": "用户名 {{username}} 已被占用", + "signup_error_creation_limit_reached": "已达到帐户创建限制", + "login_title": "请登录你的 ntfy 帐户", + "action_bar_change_display_name": "更改显示名称", + "action_bar_reservation_add": "保留主题", + "action_bar_reservation_delete": "移除保留", + "action_bar_reservation_limit_reached": "达到限制", + "action_bar_profile_title": "个人资料", + "action_bar_profile_settings": "设置", + "action_bar_profile_logout": "登出", + "action_bar_sign_in": "登录", + "action_bar_sign_up": "注册", + "nav_button_account": "帐户", + "nav_upgrade_banner_label": "升级到 ntfy Pro", + "nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件", + "alert_not_supported_context_description": "通知仅支持 HTTPS。这是 Notifications API 的限制。", + "display_name_dialog_title": "更改显示名称", + "display_name_dialog_description": "为订阅列表中显示的主题设置一个替代名称。这有助于更轻松地识别名称复杂的主题。", + "display_name_dialog_placeholder": "显示名称", + "reserve_dialog_checkbox_label": "保留主题并配置访问", + "subscribe_dialog_subscribe_button_generate_topic_name": "生成名称", + "account_basics_username_description": "嘿,那是你 ❤", + "account_basics_password_description": "更改您的帐户密码", + "account_basics_password_dialog_title": "更改密码", + "account_basics_password_dialog_current_password_label": "当前密码", + "account_basics_password_dialog_new_password_label": "新密码", + "account_basics_password_dialog_confirm_password_label": "确认密码", + "account_basics_password_dialog_button_submit": "更改密码", + "account_basics_password_dialog_current_password_incorrect": "密码错误", + "account_usage_title": "使用量", + "account_usage_of_limit": "{{limit}} 的", + "account_usage_unlimited": "无限", + "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", + "account_basics_tier_title": "帐户类型", + "account_basics_tier_description": "您帐户的权限级别", + "account_basics_tier_admin": "管理员", + "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)", + "account_basics_tier_admin_suffix_no_tier": "(无等级)", + "account_basics_tier_basic": "基础版", + "account_basics_tier_free": "免费", + "account_basics_tier_upgrade_button": "升级到专业版", + "account_basics_tier_change_button": "改变", + "account_basics_tier_paid_until": "订阅已支付至 {{date}},并将自动续订", + "account_basics_tier_manage_billing_button": "管理计费", + "account_usage_messages_title": "已发布消息", + "account_usage_emails_title": "已发送电子邮件", + "account_usage_reservations_title": "保留主题", + "account_usage_reservations_none": "此帐户没有保留主题", + "account_usage_attachment_storage_title": "附件存储", + "account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除", + "account_upgrade_dialog_button_pay_now": "立即付款并订阅", + "account_upgrade_dialog_button_cancel_subscription": "取消订阅", + "account_upgrade_dialog_button_update_subscription": "更新订阅", + "account_tokens_dialog_title_create": "创建访问令牌", + "account_tokens_dialog_title_edit": "编辑访问令牌", + "account_tokens_dialog_title_delete": "删除访问令牌", + "account_tokens_dialog_button_cancel": "取消", + "account_tokens_dialog_expires_label": "访问令牌过期于", + "account_tokens_dialog_expires_unchanged": "保持过期日期不变", + "account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小时后过期", + "account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天后过期", + "account_tokens_dialog_expires_never": "令牌永不过期", + "account_tokens_delete_dialog_title": "删除访问令牌", + "account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 此操作无法撤消。", + "account_tokens_delete_dialog_submit_button": "永久删除令牌", + "prefs_users_description_no_sync": "用户和密码不会同步到您的帐户。", + "prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户", + "prefs_reservations_title": "保留主题", + "prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。", + "prefs_reservations_limit_reached": "您已达到保留主题限制。", + "prefs_reservations_add_button": "添加保留主题", + "prefs_reservations_edit_button": "编辑主题访问", + "prefs_reservations_delete_button": "重置主题访问", + "prefs_reservations_table": "保留主题表格", + "prefs_reservations_table_topic_header": "主题", + "prefs_reservations_table_access_header": "访问", + "prefs_reservations_table_everyone_deny_all": "只有我可以发布和订阅", + "prefs_reservations_table_everyone_read_only": "我可以发布和订阅,每个人都可以订阅", + "prefs_reservations_table_everyone_write_only": "我可以发布和订阅,每个人都可以发布", + "prefs_reservations_table_everyone_read_write": "每个人都可以发布和订阅", + "prefs_reservations_table_not_subscribed": "未订阅", + "prefs_reservations_table_click_to_subscribe": "点击以订阅", + "prefs_reservations_dialog_title_add": "保留主题", + "prefs_reservations_dialog_title_edit": "编辑保留主题", + "prefs_reservations_dialog_title_delete": "删除主题保留", + "prefs_reservations_dialog_description": "保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。", + "prefs_reservations_dialog_topic_label": "主题", + "prefs_reservations_dialog_access_label": "访问", + "reservation_delete_dialog_description": "删除保留会放弃对该主题的所有权,并允许其他人保留它。您可以保留或删除现有邮件和附件。", + "reservation_delete_dialog_action_keep_title": "保留缓存的邮件和附件", + "reservation_delete_dialog_action_keep_description": "缓存在服务器上的消息和附件将对知道主题名称的人公开可见。", + "reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件", + "reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。", + "reservation_delete_dialog_submit_button": "删除保留", + "account_delete_dialog_description": "这将永久删除您的帐户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。", + "account_delete_dialog_label": "密码", + "account_delete_dialog_button_cancel": "取消", + "account_delete_dialog_button_submit": "永久删除帐户", + "account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。", + "account_upgrade_dialog_title": "更改帐户等级", + "account_upgrade_dialog_cancel_warning": "这将取消您的订阅,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息将被删除。", + "account_upgrade_dialog_proration_info": "按比例分配:在付费计划之间升级时,差价将被立刻收取。在降级到较低级别时,余额将被用于支付未来的账单周期。", + "account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 1 项保留。您可以在设置中删除保留。", + "account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 {{count}} 项保留。您可以在设置中删除保留。", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} 条保留主题", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} 条每日消息", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} 条每日邮件", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} 每个文件", + "signup_form_confirm_password": "确认密码", + "signup_form_button_submit": "注册", + "signup_form_toggle_password_visibility": "切换密码可见性", + "signup_title": "创建一个 ntfy 帐户", + "signup_form_username": "用户名", + "signup_form_password": "密码", + "signup_already_have_account": "已有帐户?登录!", + "signup_disabled": "注册已禁用", + "login_form_button_submit": "登录", + "login_link_signup": "注册", + "login_disabled": "登录已禁用", + "action_bar_account": "帐户", + "action_bar_reservation_edit": "更改保留", + "subscribe_dialog_error_topic_already_reserved": "主题已保留", + "account_basics_title": "帐户", + "account_basics_username_title": "用户名", + "account_basics_username_admin_tooltip": "你是管理员", + "account_basics_password_title": "密码", + "account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的帐户将很快被降级。", + "account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费帐户。", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间", + "account_upgrade_dialog_tier_selected_label": "已选", + "account_upgrade_dialog_tier_current_label": "当前", + "account_upgrade_dialog_button_cancel": "取消", + "account_upgrade_dialog_button_redirect_signup": "立即注册", + "account_tokens_title": "访问令牌", + "account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的帐户凭据。查看文档以了解更多信息。", + "account_tokens_table_token_header": "令牌", + "account_tokens_table_label_header": "标签", + "account_tokens_table_last_access_header": "最后访问", + "account_tokens_table_expires_header": "过期", + "account_tokens_table_never_expires": "永不过期", + "account_tokens_table_current_session": "当前浏览器会话", + "common_copy_to_clipboard": "复制到剪贴板", + "account_tokens_table_copied_to_clipboard": "已复制访问令牌", + "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", + "account_tokens_table_create_token_button": "创建访问令牌", + "account_tokens_table_last_origin_tooltip": "于IP地址 {{ip}},点击查找", + "account_tokens_dialog_label": "标签,例如:Radarr 通知", + "account_tokens_dialog_button_create": "创建令牌", + "account_tokens_dialog_button_update": "更新令牌", + "account_basics_tier_interval_monthly": "每月", + "account_basics_tier_interval_yearly": "每年", + "account_upgrade_dialog_interval_monthly": "每月", + "account_upgrade_dialog_interval_yearly": "每年", + "account_upgrade_dialog_interval_yearly_discount_save": "节省 {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "节省高达 {{discount}}%", + "account_upgrade_dialog_tier_features_no_reservations": "无保留主题", + "account_upgrade_dialog_tier_price_per_month": "月", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。", + "account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。", + "account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接联系我们 。", + "account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的网站 。", + "publish_dialog_call_item": "拨打电话 {{number}}", + "publish_dialog_call_label": "拨号", + "publish_dialog_chip_call_label": "拨号", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "未验证的手机号", + "account_basics_phone_numbers_title": "电话号码", + "account_basics_phone_numbers_description": "电话通知", + "account_basics_phone_numbers_dialog_description": "要使用来电通知功能,您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。", + "account_basics_phone_numbers_dialog_code_label": "验证码", + "account_basics_phone_numbers_dialog_code_placeholder": "例如:123456", + "account_basics_phone_numbers_dialog_check_verification_button": "确认码", + "account_basics_phone_numbers_dialog_channel_sms": "短信", + "account_basics_phone_numbers_dialog_channel_call": "拨打", + "publish_dialog_call_reset": "清空拨号", + "account_basics_phone_numbers_no_phone_numbers_yet": "无可执行的电话号码", + "account_basics_phone_numbers_dialog_title": "添加电话号码", + "account_basics_phone_numbers_copied_to_clipboard": "电话号码已复制到剪贴板", + "account_basics_phone_numbers_dialog_number_label": "电话号码", + "account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444", + "account_usage_calls_title": "已拨打电话", + "account_usage_calls_none": "此帐号无法拨打电话", + "account_upgrade_dialog_tier_features_reservations_one": "一条保留主题", + "account_upgrade_dialog_tier_features_emails_one": "一封每日邮件", + "account_upgrade_dialog_tier_features_calls_one": "一通每日电话", + "account_basics_phone_numbers_dialog_verify_button_sms": "发送信息", + "account_basics_phone_numbers_dialog_verify_button_call": "拨打电话", + "account_upgrade_dialog_tier_features_messages_one": "一条每日消息", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} 通每日电话", + "account_upgrade_dialog_tier_features_no_calls": "无电话呼叫", + "web_push_subscription_expiring_title": "通知将被暂停", + "web_push_subscription_expiring_body": "打开ntfy以继续接收通知", + "web_push_unknown_notification_title": "接收到未知通知", + "web_push_unknown_notification_body": "你可能需要打开网页来更新ntfy" +} diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json new file mode 100644 index 00000000..683f5a9f --- /dev/null +++ b/web/public/static/langs/zh_Hant.json @@ -0,0 +1,407 @@ +{ + "account_basics_password_description": "更改你的帳戶密碼", + "account_basics_password_dialog_button_submit": "更改密碼", + "account_basics_password_dialog_confirm_password_label": "確認密碼", + "account_basics_password_dialog_current_password_incorrect": "密碼錯誤", + "account_basics_password_dialog_current_password_label": "當前密碼", + "account_basics_password_dialog_new_password_label": "新密碼", + "account_basics_password_dialog_title": "更改密碼", + "account_basics_password_title": "密碼", + "account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼板", + "account_basics_phone_numbers_description": "電話通知", + "account_basics_phone_numbers_dialog_channel_call": "撥打", + "account_basics_phone_numbers_dialog_channel_sms": "短信", + "account_basics_phone_numbers_dialog_check_verification_button": "確認碼", + "account_basics_phone_numbers_dialog_code_label": "驗證碼", + "account_basics_phone_numbers_dialog_code_placeholder": "例如:123456", + "account_basics_phone_numbers_dialog_description": "要使用來電通知功能,你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。", + "account_basics_phone_numbers_dialog_number_label": "電話號碼", + "account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444", + "account_basics_phone_numbers_dialog_title": "新增電話號碼", + "account_basics_phone_numbers_dialog_verify_button_call": "撥打電話", + "account_basics_phone_numbers_dialog_verify_button_sms": "發送資訊", + "account_basics_phone_numbers_no_phone_numbers_yet": "無可執行的電話號碼", + "account_basics_phone_numbers_title": "電話號碼", + "account_basics_tier_admin_suffix_no_tier": "(無等級)", + "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等級)", + "account_basics_tier_admin": "管理員", + "account_basics_tier_basic": "基礎版", + "account_basics_tier_canceled_subscription": "你的訂閱已取消,並將在 {{date}} 降級為免費帳戶。", + "account_basics_tier_change_button": "改變", + "account_basics_tier_description": "你帳戶的權限級別", + "account_basics_tier_free": "免費", + "account_basics_tier_interval_monthly": "每月", + "account_basics_tier_interval_yearly": "每年", + "account_basics_tier_manage_billing_button": "管理計費", + "account_basics_tier_paid_until": "訂閱已支付至 {{date}},並將自動續訂", + "account_basics_tier_payment_overdue": "你的付款已逾期。請更新你的付款方式,否則你的帳戶將很快被降級。", + "account_basics_tier_title": "帳戶類型", + "account_basics_tier_upgrade_button": "升級到專業版", + "account_basics_title": "帳戶", + "account_basics_username_admin_tooltip": "你是管理員", + "account_basics_username_description": "嘿,那是你 ❤", + "account_basics_username_title": "用戶名", + "account_delete_description": "永久刪除你的帳戶", + "account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。", + "account_delete_dialog_button_cancel": "取消", + "account_delete_dialog_button_submit": "永久刪除帳戶", + "account_delete_dialog_description": "這將永久刪除你的帳戶,包括存儲在伺服器上的所有數據。刪除後,你的用戶名將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。", + "account_delete_dialog_label": "密碼", + "account_delete_title": "刪除帳戶", + "account_tokens_delete_dialog_description": "在刪除訪問令牌之前,請確保沒有應用程序或腳本正在活躍使用它。 此操作無法撤銷。", + "account_tokens_delete_dialog_submit_button": "永久删除令牌", + "account_tokens_delete_dialog_title": "刪除訪問令牌", + "account_tokens_description": "通過 ntfy API 發布和訂閱時使用訪問令牌,因此你不必發送你的帳戶憑證。查看文檔以了解更多資訊。", + "account_tokens_dialog_button_cancel": "取消", + "account_tokens_dialog_button_create": "創建令牌", + "account_tokens_dialog_button_update": "更新令牌", + "account_tokens_dialog_expires_label": "訪問令牌過期於", + "account_tokens_dialog_expires_never": "令牌永不過期", + "account_tokens_dialog_expires_unchanged": "保持過期日期不變", + "account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天後過期", + "account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小時後過期", + "account_tokens_dialog_label": "標籤,例如:Radarr 通知", + "account_tokens_dialog_title_create": "創建訪問令牌", + "account_tokens_dialog_title_delete": "刪除訪問令牌", + "account_tokens_dialog_title_edit": "編輯訪問令牌", + "account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除當前會話令牌", + "account_tokens_table_copied_to_clipboard": "已複製訪問令牌", + "account_tokens_table_create_token_button": "創建訪問令牌", + "account_tokens_table_current_session": "當前瀏覽器會話", + "account_tokens_table_expires_header": "過期", + "account_tokens_table_label_header": "標籤", + "account_tokens_table_last_access_header": "最後訪問", + "account_tokens_table_last_origin_tooltip": "於IP地址 {{ip}},點擊查找", + "account_tokens_table_never_expires": "永不過期", + "account_tokens_table_token_header": "令牌", + "account_tokens_title": "訪問令牌", + "account_upgrade_dialog_billing_contact_email": "有關賬單問題,請直接聯繫我們 。", + "account_upgrade_dialog_billing_contact_website": "有關賬單問題,請參考我們的網站 。", + "account_upgrade_dialog_button_cancel_subscription": "取消訂閱", + "account_upgrade_dialog_button_cancel": "取消", + "account_upgrade_dialog_button_pay_now": "立即付款並訂閱", + "account_upgrade_dialog_button_redirect_signup": "立即註冊", + "account_upgrade_dialog_button_update_subscription": "更新訂閱", + "account_upgrade_dialog_cancel_warning": "這將取消你的訂閱,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及緩存在伺服器上的訊息將被刪除。", + "account_upgrade_dialog_interval_monthly": "每月", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "節省高達 {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save": "節省 {{discount}}%", + "account_upgrade_dialog_interval_yearly": "每年", + "account_upgrade_dialog_proration_info": "按比例分配:在付費計劃之間升級時,差價將被立刻收取。在降級到較低級別時,餘額將被用於支付未來的賬單周期。", + "account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 1 項保留。你可以在設置中刪除保留。", + "account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 {{count}} 項保留。你可以在設置中刪除保留。", + "account_upgrade_dialog_tier_current_label": "當前", + "account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}} ", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間", + "account_upgrade_dialog_tier_features_calls_one": "每日一通電話", + "account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話", + "account_upgrade_dialog_tier_features_emails_one": "每日一封郵件", + "account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 條郵件", + "account_upgrade_dialog_tier_features_messages_one": "每日一條訊息", + "account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 條訊息", + "account_upgrade_dialog_tier_features_no_calls": "沒有電話", + "account_upgrade_dialog_tier_features_no_reservations": "無保留主題", + "account_upgrade_dialog_tier_features_reservations_one": "保留一條主題", + "account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 條主題", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月計費。", + "account_upgrade_dialog_tier_price_billed_yearly": "{{價格}} 按年計費。節省 {{save}}。", + "account_upgrade_dialog_tier_price_per_month": "月", + "account_upgrade_dialog_tier_selected_label": "已選", + "account_upgrade_dialog_title": "更改帳戶等級", + "account_usage_attachment_storage_description": "每個文件 {{filesize}},在 {{expiry}} 後刪除", + "account_usage_attachment_storage_title": "附件存儲", + "account_usage_basis_ip_description": "此帳戶的使用統計資訊和限制基於你的 IP 地址,因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。", + "account_usage_calls_none": "此帳號無法撥打電話", + "account_usage_calls_title": "已撥打電話", + "account_usage_cannot_create_portal_session": "無法打開計費門戶", + "account_usage_emails_title": "已發送電子郵件", + "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", + "account_usage_messages_title": "已發布訊息", + "account_usage_of_limit": "{{limit}} 的", + "account_usage_reservations_none": "此帳戶沒有保留主題", + "account_usage_reservations_title": "保留主題", + "account_usage_title": "使用量", + "account_usage_unlimited": "無限", + "action_bar_account": "帳戶", + "action_bar_change_display_name": "更改顯示名稱", + "action_bar_clear_notifications": "清除所有通知", + "action_bar_logo_alt": "ntfy 標識", + "action_bar_mute_notifications": "靜音", + "action_bar_profile_logout": "登出", + "action_bar_profile_settings": "設定", + "action_bar_profile_title": "個人資料", + "action_bar_reservation_add": "保留主題", + "action_bar_reservation_delete": "移除保留", + "action_bar_reservation_edit": "更改保留", + "action_bar_reservation_limit_reached": "達到限制", + "action_bar_send_test_notification": "發送測試通知", + "action_bar_settings": "設定", + "action_bar_show_menu": "顯示選單", + "action_bar_sign_in": "登錄", + "action_bar_sign_up": "註冊", + "action_bar_toggle_action_menu": "開啟或關閉操作選單", + "action_bar_toggle_mute": "通知靜音/解除通知靜音", + "action_bar_unmute_notifications": "取消靜音", + "action_bar_unsubscribe": "取消訂閱", + "alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕", + "alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式", + "alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知。", + "alert_notification_permission_denied_title": "已禁用通知", + "alert_notification_permission_required_button": "現在授予", + "alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限。", + "alert_notification_permission_required_title": "已禁用通知", + "alert_not_supported_context_description": "通知僅支援 HTTPS。這是 Notifications API 的限制。", + "alert_not_supported_description": "你的瀏覽器不支援通知。", + "alert_not_supported_title": "不支援通知", + "common_add": "新增", + "common_back": "返回", + "common_cancel": "取消", + "common_copy_to_clipboard": "複製到剪貼板", + "common_save": "保存", + "display_name_dialog_description": "為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。", + "display_name_dialog_placeholder": "顯示名稱", + "display_name_dialog_title": "更改顯示名稱", + "emoji_picker_search_clear": "清除搜索", + "emoji_picker_search_placeholder": "查找表情符號", + "error_boundary_button_copy_stack_trace": "複製堆疊追踪", + "error_boundary_button_reload_ntfy": "重新加載 ntfy", + "error_boundary_description": "這顯然不應該發生。對此非常抱歉。
如果你有時間,請在GitHub上報告,或通過DiscordMatrix告訴我們。", + "error_boundary_gathering_info": "收集更多資訊……", + "error_boundary_stack_trace": "堆疊追踪", + "error_boundary_title": "天啊,ntfy 崩潰了", + "error_boundary_unsupported_indexeddb_description": "Ntfy Web應用程式需要IndexedDB才能運行,且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。

儘管這很不幸,但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義,因為所有東西都存儲在瀏覽器存儲中。你可以在本GitHub問題中閱讀有關它的更多資訊,或者在DiscordMatrix上與我們交談。", + "error_boundary_unsupported_indexeddb_title": "不支援隱私瀏覽", + "login_disabled": "登錄已禁用", + "login_form_button_submit": "登錄", + "login_link_signup": "註冊", + "login_title": "請登錄你的 ntfy 帳戶", + "message_bar_error_publishing": "發佈通知時出錯", + "message_bar_publish": "發布訊息", + "message_bar_show_dialog": "顯示發布對話框", + "message_bar_type_message": "在此處輸入訊息", + "nav_button_account": "帳戶", + "nav_button_all_notifications": "全部通知", + "nav_button_connecting": "正在連接", + "nav_button_documentation": "文檔", + "nav_button_muted": "已暫停通知", + "nav_button_publish_message": "發布通知", + "nav_button_settings": "設定", + "nav_button_subscribe": "訂閱主題", + "nav_topics_title": "訂閱主題", + "nav_upgrade_banner_description": "保留主題,更多訊息和郵件,以及更大的附件", + "nav_upgrade_banner_label": "升級到 ntfy Pro", + "notifications_actions_failed_notification": "通知失敗", + "notifications_actions_http_request_title": "發送 HTTP {{method}} 到 {{url}}", + "notifications_actions_not_supported": "網頁應用程序不支援此操作", + "notifications_actions_open_url_title": "轉到 {{url}}", + "notifications_attachment_copy_url_button": "複製連結地址", + "notifications_attachment_copy_url_title": "將附件中連結地址複製到剪貼板", + "notifications_attachment_file_app": "安卓應用程式", + "notifications_attachment_file_audio": "聲音文件", + "notifications_attachment_file_document": "其他文件", + "notifications_attachment_file_image": "圖片文件", + "notifications_attachment_file_video": "影片文件", + "notifications_attachment_image": "附件圖片", + "notifications_attachment_link_expired": "下載連結已過期", + "notifications_attachment_link_expires": "連結在 {{date}} 過期", + "notifications_attachment_open_button": "打開附件", + "notifications_attachment_open_title": "轉到 {{url}}", + "notifications_click_copy_url_button": "複製鏈結", + "notifications_click_copy_url_title": "複製鏈結地址到剪貼板", + "notifications_click_open_button": "打開鏈結", + "notifications_copied_to_clipboard": "複製到剪貼板", + "notifications_delete": "刪除", + "notifications_example": "示例", + "notifications_list_item": "通知", + "notifications_list": "通知列表", + "notifications_loading": "正在加載通知……", + "notifications_mark_read": "標記為已讀", + "notifications_more_details": "有關更多資訊,請查看網站文檔。", + "notifications_new_indicator": "新通知", + "notifications_none_for_any_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。", + "notifications_none_for_any_title": "你尚未收到任何通知。", + "notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。", + "notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。", + "notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。", + "notifications_no_subscriptions_title": "看起來你還未有任何訂閱", + "notifications_priority_x": "優先級 {{priority}}", + "notifications_tags": "標記", + "prefs_appearance_language_title": "語言", + "prefs_appearance_theme_dark": "黑暗模式", + "prefs_appearance_theme_light": "光亮模式", + "prefs_appearance_theme_system": "系統 (預設)", + "prefs_appearance_theme_title": "主題", + "prefs_appearance_title": "外觀", + "prefs_notifications_delete_after_never_description": "永不自動刪除通知", + "prefs_notifications_delete_after_never": "從不", + "prefs_notifications_delete_after_one_day_description": "一天後自動刪除通知", + "prefs_notifications_delete_after_one_day": "一天後", + "prefs_notifications_delete_after_one_month_description": "一個月後自動刪除通知", + "prefs_notifications_delete_after_one_month": "一個月後", + "prefs_notifications_delete_after_one_week_description": "一周後自動刪除通知", + "prefs_notifications_delete_after_one_week": "一周後", + "prefs_notifications_delete_after_three_hours_description": "三小時後自動刪除通知", + "prefs_notifications_delete_after_three_hours": "三小時後", + "prefs_notifications_delete_after_title": "刪除通知", + "prefs_notifications_min_priority_any": "任意優先級", + "prefs_notifications_min_priority_default_and_higher": "默認優先級或更高", + "prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先級如何", + "prefs_notifications_min_priority_description_max": "僅顯示最高優先級的通知", + "prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先級為{{number}}({{name}})或以上的通知", + "prefs_notifications_min_priority_high_and_higher": "高優先級或更高", + "prefs_notifications_min_priority_low_and_higher": "低優先級或更高", + "prefs_notifications_min_priority_max_only": "僅最高優先級", + "prefs_notifications_min_priority_title": "最低優先級", + "prefs_notifications_sound_description_none": "收到通知時不播放任何聲音", + "prefs_notifications_sound_description_some": "收到通知時播放 {{sound}} 聲音", + "prefs_notifications_sound_no_sound": "靜音", + "prefs_notifications_sound_play": "播放選中聲音", + "prefs_notifications_sound_title": "通知提示音", + "prefs_notifications_title": "通知", + "prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)", + "prefs_notifications_web_push_disabled": "己暫用", + "prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)", + "prefs_notifications_web_push_enabled": "己為 {{server}} 啟用", + "prefs_notifications_web_push_title": "背景通知", + "prefs_reservations_add_button": "新增保留主題", + "prefs_reservations_delete_button": "重置主題訪問", + "prefs_reservations_description": "你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", + "prefs_reservations_dialog_access_label": "訪問", + "prefs_reservations_dialog_description": "保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", + "prefs_reservations_dialog_title_add": "保留主題", + "prefs_reservations_dialog_title_delete": "刪除主題保留", + "prefs_reservations_dialog_title_edit": "編輯保留主題", + "prefs_reservations_dialog_topic_label": "主題", + "prefs_reservations_edit_button": "編輯主題訪問", + "prefs_reservations_limit_reached": "你已達到保留主題限制。", + "prefs_reservations_table_access_header": "訪問", + "prefs_reservations_table_click_to_subscribe": "點擊以訂閱", + "prefs_reservations_table_everyone_deny_all": "只有我可以發佈和訂閱", + "prefs_reservations_table_everyone_read_only": "我可以發佈和訂閱,每個人都可以訂閱", + "prefs_reservations_table_everyone_read_write": "每個人都可以發佈和訂閱", + "prefs_reservations_table_everyone_write_only": "我可以發佈和訂閱,每個人都可以發佈", + "prefs_reservations_table_not_subscribed": "未訂閱", + "prefs_reservations_table_topic_header": "主題", + "prefs_reservations_table": "保留主題表格", + "prefs_reservations_title": "保留主題", + "prefs_users_add_button": "新增使用者", + "prefs_users_delete_button": "刪除用戶", + "prefs_users_description_no_sync": "用戶和密碼不會同步到你的賬戶。", + "prefs_users_description": "在此處新增/刪除受保護主題的使用者。請注意,使用者名和密碼將存儲在瀏覽器的本地存儲中。", + "prefs_users_dialog_base_url_label": "服務連結地址,例如 https://ntfy.sh", + "prefs_users_dialog_password_label": "密碼", + "prefs_users_dialog_title_add": "新增使用者", + "prefs_users_dialog_title_edit": "編輯使用者", + "prefs_users_dialog_username_label": "使用者名,例如 phil", + "prefs_users_edit_button": "編輯用戶", + "prefs_users_table_base_url_header": "服務連結地址", + "prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登錄用戶", + "prefs_users_table_user_header": "用戶", + "prefs_users_table": "用戶表", + "prefs_users_title": "管理使用者", + "priority_default": "預設", + "priority_high": "高", + "priority_low": "低", + "priority_max": "最高", + "priority_min": "最低", + "publish_dialog_attached_file_filename_placeholder": "附件文件名", + "publish_dialog_attached_file_remove": "刪除附件文件", + "publish_dialog_attached_file_title": "附件文件:", + "publish_dialog_attach_label": "附件連結地址", + "publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 文件限制和配額,剩餘 {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 文件限制", + "publish_dialog_attachment_limits_quota_reached": "超過配額,剩餘 {{remainingBytes}}", + "publish_dialog_attach_placeholder": "使用鏈結地址附加文件,例如 https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "移除附件鏈結地址", + "publish_dialog_base_url_label": "服務鏈結地址", + "publish_dialog_base_url_placeholder": "服務鏈結地址,例如 https://example.com", + "publish_dialog_button_cancel_sending": "取消發送", + "publish_dialog_button_cancel": "取消", + "publish_dialog_button_send": "發送", + "publish_dialog_call_item": "撥打電話 {{number}}", + "publish_dialog_call_label": "撥號", + "publish_dialog_call_reset": "清空撥號", + "publish_dialog_checkbox_markdown": "格式化為 Markdown", + "publish_dialog_checkbox_publish_another": "發布另一個", + "publish_dialog_chip_attach_file_label": "本地文件附件", + "publish_dialog_chip_attach_url_label": "鏈結附件地址", + "publish_dialog_chip_call_label": "撥號", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "未驗證的電話號碼", + "publish_dialog_chip_click_label": "點擊鏈結地址", + "publish_dialog_chip_delay_label": "延期投遞", + "publish_dialog_chip_email_label": "轉發郵件", + "publish_dialog_chip_topic_label": "變更主題", + "publish_dialog_click_label": "點擊鏈結地址", + "publish_dialog_click_placeholder": "點擊通知時打開鏈結地址", + "publish_dialog_click_reset": "移除點擊連結地址", + "publish_dialog_delay_label": "延期", + "publish_dialog_delay_placeholder": "延期投遞,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(僅限英語)", + "publish_dialog_delay_reset": "刪除延期投遞", + "publish_dialog_details_examples_description": "有關所有發送功能的範例和詳細說明,請參閱文檔。", + "publish_dialog_drop_file_here": "將文件拖拽至此", + "publish_dialog_email_label": "電子郵件", + "publish_dialog_email_placeholder": "將通知轉發到的地址,例如 phil@example.com", + "publish_dialog_email_reset": "移除電子郵件轉發", + "publish_dialog_emoji_picker_show": "選擇表情符號", + "publish_dialog_filename_label": "文件名", + "publish_dialog_filename_placeholder": "附件文件名", + "publish_dialog_message_label": "訊息", + "publish_dialog_message_placeholder": "在此輸入訊息", + "publish_dialog_message_published": "已發布通知", + "publish_dialog_other_features": "其它功能:", + "publish_dialog_priority_default": "默認優先級", + "publish_dialog_priority_high": "高優先級", + "publish_dialog_priority_label": "優先級", + "publish_dialog_priority_low": "低優先級", + "publish_dialog_priority_max": "最高優先級", + "publish_dialog_priority_min": "最低優先級", + "publish_dialog_progress_uploading_detail": "正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……", + "publish_dialog_progress_uploading": "正在上傳……", + "publish_dialog_tags_label": "標記", + "publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup", + "publish_dialog_title_label": "主題", + "publish_dialog_title_no_topic": "發布通知", + "publish_dialog_title_placeholder": "主題標題,例如 磁碟空間警告", + "publish_dialog_title_topic": "發布到 {{topic}}", + "publish_dialog_topic_label": "主題名稱", + "publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts", + "publish_dialog_topic_reset": "重置主題", + "reservation_delete_dialog_action_delete_description": "緩存的郵件和附件將被永久刪除。此操作無法撤銷。", + "reservation_delete_dialog_action_delete_title": "刪除緩存的郵件和附件", + "reservation_delete_dialog_action_keep_description": "緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。", + "reservation_delete_dialog_action_keep_title": "保留緩存的郵件和附件", + "reservation_delete_dialog_description": "刪除保留會放棄對該主題的所有權,並允許其他人保留它。你可以保留或刪除現有郵件和附件。", + "reservation_delete_dialog_submit_button": "刪除保留", + "reserve_dialog_checkbox_label": "保留主題並配置訪問", + "signup_already_have_account": "已有帳戶?登錄!", + "signup_disabled": "註冊已禁用", + "signup_error_creation_limit_reached": "已達到帳戶創建限制", + "signup_error_username_taken": "用戶名 {{username}} 已被取用", + "signup_form_button_submit": "註冊", + "signup_form_confirm_password": "確認密碼", + "signup_form_password": "密碼", + "signup_form_toggle_password_visibility": "切換密碼可見性", + "signup_form_username": "用戶名", + "signup_title": "創建一個 ntfy 帳戶", + "subscribe_dialog_error_topic_already_reserved": "主題已保留", + "subscribe_dialog_error_user_anonymous": "匿名", + "subscribe_dialog_error_user_not_authorized": "未授權 {{username}} 使用者", + "subscribe_dialog_login_button_login": "登入", + "subscribe_dialog_login_description": "本主題受密碼保護,請輸入用戶名和密碼以訂閱。", + "subscribe_dialog_login_password_label": "密碼", + "subscribe_dialog_login_title": "請登錄", + "subscribe_dialog_login_username_label": "用戶名,例如 phil", + "subscribe_dialog_subscribe_base_url_label": "服務地址地址", + "subscribe_dialog_subscribe_button_cancel": "取消", + "subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱", + "subscribe_dialog_subscribe_button_subscribe": "訂閱", + "subscribe_dialog_subscribe_description": "主題可能不受密碼保護,因此請選擇一個不容易被猜中的名字。訂閱後,你可以使用 PUT/POST 通知。", + "subscribe_dialog_subscribe_title": "訂閱主題", + "subscribe_dialog_subscribe_topic_placeholder": "主題名,例如 phil_alerts", + "subscribe_dialog_subscribe_use_another_background_info": "當網頁程式未開啟, 將不會收到來自其他伺服器的通知", + "subscribe_dialog_subscribe_use_another_label": "使用其他伺服器", + "web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知", + "web_push_subscription_expiring_title": "通知會被暫停", + "web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy", + "web_push_unknown_notification_title": "接收到不明通知" +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 00000000..56d66f16 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,262 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching"; +import { NavigationRoute, registerRoute } from "workbox-routing"; +import { NetworkFirst } from "workbox-strategies"; +import { clientsClaim } from "workbox-core"; + +import { dbAsync } from "../src/app/db"; + +import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; +import initI18n from "../src/app/i18n"; + +/** + * General docs for service workers and PWAs: + * https://vite-pwa-org.netlify.app/guide/ + * https://developer.chrome.com/docs/workbox/ + * + * This file uses the (event) => event.waitUntil() pattern. + * This is because the event handler itself cannot be async, but + * the service worker needs to stay active while the promise completes. + */ + +const broadcastChannel = new BroadcastChannel("web-push-broadcast"); + +const addNotification = async ({ subscriptionId, message }) => { + const db = await dbAsync(); + + await db.notifications.add({ + ...message, + subscriptionId, + // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, + }); + + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); + + const badgeCount = await db.notifications.where({ new: 1 }).count(); + console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); + self.navigator.setAppBadge?.(badgeCount); +}; + +/** + * Handle a received web push message and show notification. + * + * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) + * receives the broadcast and plays a sound (see web/src/app/WebPush.js). + */ +const handlePushMessage = async (data) => { + const { subscription_id: subscriptionId, message } = data; + + broadcastChannel.postMessage(message); // To potentially play sound + + await addNotification({ subscriptionId, message }); + await self.registration.showNotification( + ...toNotificationParams({ + subscriptionId, + message, + defaultTitle: message.topic, + topicRoute: new URL(message.topic, self.location.origin).toString(), + }) + ); +}; + +/** + * Handle a received web push subscription expiring. + */ +const handlePushSubscriptionExpiring = async (data) => { + const t = await initI18n(); + + await self.registration.showNotification(t("web_push_subscription_expiring_title"), { + body: t("web_push_subscription_expiring_body"), + icon, + data, + badge, + }); +}; + +/** + * Handle unknown push message. We can't ignore the push, since + * permission can be revoked by the browser. + */ +const handlePushUnknown = async (data) => { + const t = await initI18n(); + + await self.registration.showNotification(t("web_push_unknown_notification_title"), { + body: t("web_push_unknown_notification_body"), + icon, + data, + badge, + }); +}; + +/** + * Handle a received web push notification + * @param {object} data see server/types.go, type webPushPayload + */ +const handlePush = async (data) => { + if (data.event === "message") { + await handlePushMessage(data); + } else if (data.event === "subscription_expiring") { + await handlePushSubscriptionExpiring(data); + } else { + await handlePushUnknown(data); + } +}; + +/** + * Handle a user clicking on the displayed notification from `showNotification`. + * This is also called when the user clicks on an action button. + */ +const handleClick = async (event) => { + const t = await initI18n(); + + const clients = await self.clients.matchAll({ type: "window" }); + + const rootUrl = new URL(self.location.origin); + const rootClient = clients.find((client) => client.url === rootUrl.toString()); + // perhaps open on another topic + const fallbackClient = clients[0]; + + if (!event.notification.data?.message) { + // e.g. something other than a message, e.g. a subscription_expiring event + // simply open the web app on the root route (/) + if (rootClient) { + rootClient.focus(); + } else if (fallbackClient) { + fallbackClient.focus(); + fallbackClient.navigate(rootUrl.toString()); + } else { + self.clients.openWindow(rootUrl); + } + event.notification.close(); + } else { + const { message, topicRoute } = event.notification.data; + + if (event.action) { + const action = event.notification.data.message.actions.find(({ label }) => event.action === label); + + if (action.action === "view") { + self.clients.openWindow(action.url); + } else if (action.action === "http") { + try { + const response = await fetch(action.url, { + method: action.method ?? "POST", + headers: action.headers ?? {}, + body: action.body, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + } catch (e) { + console.error("[ServiceWorker] Error performing http action", e); + self.registration.showNotification(`${t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, { + body: e.message, + icon, + badge, + }); + } + } + + if (action.clear) { + event.notification.close(); + } + } else if (message.click) { + self.clients.openWindow(message.click); + + event.notification.close(); + } else { + // If no action was clicked, and the message doesn't have a click url: + // - first try focus an open tab on the `/:topic` route + // - if not, use an open tab on the root route (`/`) and navigate to the topic + // - if not, use whichever tab we have open and navigate to the topic + // - finally, open a new tab focused on the topic + + const topicClient = clients.find((client) => client.url === topicRoute); + + if (topicClient) { + topicClient.focus(); + } else if (rootClient) { + rootClient.focus(); + rootClient.navigate(topicRoute); + } else if (fallbackClient) { + fallbackClient.focus(); + fallbackClient.navigate(topicRoute); + } else { + self.clients.openWindow(topicRoute); + } + + event.notification.close(); + } + } +}; + +self.addEventListener("install", () => { + console.log("[ServiceWorker] Installed"); + self.skipWaiting(); +}); + +self.addEventListener("activate", () => { + console.log("[ServiceWorker] Activated"); + self.skipWaiting(); +}); + +// There's no good way to test this, and Chrome doesn't seem to implement this, +// so leaving it for now +self.addEventListener("pushsubscriptionchange", (event) => { + console.log("[ServiceWorker] PushSubscriptionChange"); + console.log(event); +}); + +self.addEventListener("push", (event) => { + const data = event.data.json(); + console.log("[ServiceWorker] Received Web Push Event", { event, data }); + event.waitUntil(handlePush(data)); +}); + +self.addEventListener("notificationclick", (event) => { + console.log("[ServiceWorker] NotificationClick"); + event.waitUntil(handleClick(event)); +}); + +// See https://vite-pwa-org.netlify.app/guide/inject-manifest.html#service-worker-code +// self.__WB_MANIFEST is the workbox injection point that injects the manifest of the +// vite dist files and their revision ids, for example: +// [{"revision":"aaabbbcccdddeeefff12345","url":"/index.html"},...] +precacheAndRoute( + // eslint-disable-next-line no-underscore-dangle + self.__WB_MANIFEST +); + +// Claim all open windows +clientsClaim(); +// Delete any cached old dist files from previous service worker versions +cleanupOutdatedCaches(); + +if (!import.meta.env.DEV) { + // we need the app_root setting, so we import the config.js file from the go server + // this does NOT include the same base_url as the web app running in a window, + // since we don't have access to `window` like in `src/app/config.js` + self.importScripts("/config.js"); + + // this is the fallback single-page-app route, matching vite.config.js PWA config, + // and is served by the go web server. It is needed for the single-page-app to work. + // https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route + registerRoute( + new NavigationRoute(createHandlerBoundToURL("/app.html"), { + allowlist: [ + // the app root itself, could be /, or not + new RegExp(`^${config.app_root}$`), + ], + }) + ); + + // the manifest excludes config.js (see vite.config.js) since the dist-file differs from the + // actual config served by the go server. this adds it back with `NetworkFirst`, so that the + // most recent config from the go server is cached, but the app still works if the network + // is unavailable. this is important since there's no "refresh" button in the installed pwa + // to force a reload. + registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst()); +} diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js new file mode 100644 index 00000000..d9380438 --- /dev/null +++ b/web/src/app/AccountApi.js @@ -0,0 +1,435 @@ +import i18n from "i18next"; +import { + accountBillingPortalUrl, + accountBillingSubscriptionUrl, + accountPasswordUrl, + accountPhoneUrl, + accountPhoneVerifyUrl, + accountReservationSingleUrl, + accountReservationUrl, + accountSettingsUrl, + accountSubscriptionUrl, + accountTokenUrl, + accountUrl, + maybeWithBearerAuth, + tiersUrl, + withBasicAuth, + withBearerAuth, +} from "./utils"; +import session from "./Session"; +import subscriptionManager from "./SubscriptionManager"; +import prefs from "./Prefs"; +import routes from "../components/routes"; +import { fetchOrThrow, UnauthorizedError } from "./errors"; + +const delayMillis = 45000; // 45 seconds +const intervalMillis = 900000; // 15 minutes + +class AccountApi { + constructor() { + this.timer = null; + this.listener = null; // Fired when account is fetched from remote + this.tiers = null; // Cached + } + + registerListener(listener) { + this.listener = listener; + } + + resetListener() { + this.listener = null; + } + + async login(user) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Checking auth for ${url}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBasicAuth({}, user.username, user.password), + }); + const json = await response.json(); // May throw SyntaxError + if (!json.token) { + throw new Error(`Unexpected server response: Cannot find token`); + } + return json.token; + } + + async logout() { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + }); + } + + async create(username, password) { + const url = accountUrl(config.base_url); + const body = JSON.stringify({ + username, + password, + }); + console.log(`[AccountApi] Creating user account ${url}`); + await fetchOrThrow(url, { + method: "POST", + body, + }); + } + + async get() { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Fetching user account ${url}`); + const response = await fetchOrThrow(url, { + headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous + }); + const account = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Account`, account); + if (this.listener) { + this.listener(account); + } + return account; + } + + async delete(password) { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Deleting user account ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password, + }), + }); + } + + async changePassword(currentPassword, newPassword) { + const url = accountPasswordUrl(config.base_url); + console.log(`[AccountApi] Changing account password ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password: currentPassword, + new_password: newPassword, + }), + }); + } + + async createToken(label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + label, + expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0, + }; + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body), + }); + } + + async updateToken(token, label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + token, + label, + }; + if (expires > 0) { + body.expires = Math.floor(Date.now() / 1000) + expires; + } + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body), + }); + } + + async extendToken() { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Extending user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + }); + } + + async deleteToken(token) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Deleting user access token ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({ "X-Token": token }, session.token()), + }); + } + + async updateSettings(payload) { + const url = accountSettingsUrl(config.base_url); + const body = JSON.stringify(payload); + console.log(`[AccountApi] Updating user account ${url}: ${body}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body, + }); + } + + async addSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic, + }); + console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body, + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async updateSubscription(baseUrl, topic, payload) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic, + ...payload, + }); + console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body, + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async deleteSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Removing user subscription ${url}`); + const headers = { + "X-BaseURL": baseUrl, + "X-Topic": topic, + }; + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()), + }); + } + + async upsertReservation(topic, everyone) { + const url = accountReservationUrl(config.base_url); + console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + topic, + everyone, + }), + }); + } + + async deleteReservation(topic, deleteMessages) { + const url = accountReservationSingleUrl(config.base_url, topic); + console.log(`[AccountApi] Removing topic reservation ${url}`); + const headers = { + "X-Delete-Messages": deleteMessages ? "true" : "false", + }; + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()), + }); + } + + async billingTiers() { + if (this.tiers) { + return this.tiers; + } + const url = tiersUrl(config.base_url); + console.log(`[AccountApi] Fetching billing tiers`); + const response = await fetchOrThrow(url); // No auth needed! + this.tiers = await response.json(); // May throw SyntaxError + return this.tiers; + } + + async createBillingSubscription(tier, interval) { + console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); + return this.upsertBillingSubscription("POST", tier, interval); + } + + async updateBillingSubscription(tier, interval) { + console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); + return this.upsertBillingSubscription("PUT", tier, interval); + } + + async upsertBillingSubscription(method, tier, interval) { + const url = accountBillingSubscriptionUrl(config.base_url); + const response = await fetchOrThrow(url, { + method, + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + tier, + interval, + }), + }); + return response.json(); // May throw SyntaxError + } + + async deleteBillingSubscription() { + const url = accountBillingSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Cancelling billing subscription`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + }); + } + + async createBillingPortalSession() { + const url = accountBillingPortalUrl(config.base_url); + console.log(`[AccountApi] Creating billing portal session`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + }); + return response.json(); // May throw SyntaxError + } + + async verifyPhoneNumber(phoneNumber, channel) { + const url = accountPhoneVerifyUrl(config.base_url); + console.log(`[AccountApi] Sending phone verification ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + channel, + }), + }); + } + + async addPhoneNumber(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Adding phone number with verification code ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + code, + }), + }); + } + + async deletePhoneNumber(phoneNumber) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Deleting phone number ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + }), + }); + } + + async sync() { + try { + if (!session.token()) { + return null; + } + console.log(`[AccountApi] Syncing account`); + const account = await this.get(); + if (account.language) { + await i18n.changeLanguage(account.language); + } + if (account.notification) { + if (account.notification.sound) { + await prefs.setSound(account.notification.sound); + } + if (account.notification.delete_after) { + await prefs.setDeleteAfter(account.notification.delete_after); + } + if (account.notification.min_priority) { + await prefs.setMinPriority(account.notification.min_priority); + } + } + if (account.subscriptions) { + await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); + } + return account; + } catch (e) { + console.log(`[AccountApi] Error fetching account`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + return undefined; + } + } + + startWorker() { + if (this.timer !== null) { + return; + } + console.log(`[AccountApi] Starting worker`); + this.timer = setInterval(() => this.runWorker(), intervalMillis); + setTimeout(() => this.runWorker(), delayMillis); + } + + stopWorker() { + clearTimeout(this.timer); + } + + async runWorker() { + if (!session.token()) { + return; + } + console.log(`[AccountApi] Extending user access token`); + try { + await this.extendToken(); + } catch (e) { + console.log(`[AccountApi] Error extending user access token`, e); + } + } +} + +// Maps to user.Role in user/types.go +export const Role = { + ADMIN: "admin", + USER: "user", +}; + +// Maps to server.visitorLimitBasis in server/visitor.go +export const LimitBasis = { + IP: "ip", + TIER: "tier", +}; + +// Maps to stripe.SubscriptionStatus +export const SubscriptionStatus = { + ACTIVE: "active", + PAST_DUE: "past_due", +}; + +// Maps to stripe.PriceRecurringInterval +export const SubscriptionInterval = { + MONTH: "month", + YEAR: "year", +}; + +// Maps to user.Permission in user/types.go +export const Permission = { + READ_WRITE: "read-write", + READ_ONLY: "read-only", + WRITE_ONLY: "write-only", + DENY_ALL: "deny-all", +}; + +const accountApi = new AccountApi(); +export default accountApi; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 1e89bc02..b2bfd06f 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,134 +1,149 @@ import { - basicAuth, - encodeBase64, - fetchLinesIterator, - maybeWithBasicAuth, - topicShortUrl, - topicUrl, - topicUrlAuth, - topicUrlJsonPoll, - topicUrlJsonPollWithSince, userStatsUrl + fetchLinesIterator, + maybeWithAuth, + topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, + topicUrlJsonPollWithSince, + webPushUrl, } from "./utils"; import userManager from "./UserManager"; +import { fetchOrThrow } from "./errors"; class Api { - async poll(baseUrl, topic, since) { - const user = await userManager.get(baseUrl); - const shortUrl = topicShortUrl(baseUrl, topic); - const url = (since) - ? topicUrlJsonPollWithSince(baseUrl, topic, since) - : topicUrlJsonPoll(baseUrl, topic); - const messages = []; - const headers = maybeWithBasicAuth({}, user); - console.log(`[Api] Polling ${url}`); - for await (let line of fetchLinesIterator(url, headers)) { - console.log(`[Api, ${shortUrl}] Received message ${line}`); - messages.push(JSON.parse(line)); - } - return messages; + async poll(baseUrl, topic, since) { + const user = await userManager.get(baseUrl); + const shortUrl = topicShortUrl(baseUrl, topic); + const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic); + const messages = []; + const headers = maybeWithAuth({}, user); + console.log(`[Api] Polling ${url}`); + for await (const line of fetchLinesIterator(url, headers)) { + const message = JSON.parse(line); + if (message.id) { + console.log(`[Api, ${shortUrl}] Received message ${line}`); + messages.push(message); + } } + return messages; + } - async publish(baseUrl, topic, message, options) { - const user = await userManager.get(baseUrl); - console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); - const headers = {}; - const body = { - topic: topic, - message: message, - ...options - }; - const response = await fetch(baseUrl, { - method: 'PUT', - body: JSON.stringify(body), - headers: maybeWithBasicAuth(headers, user) - }); - if (response.status < 200 || response.status > 299) { - throw new Error(`Unexpected response: ${response.status}`); - } - return response; - } + async publish(baseUrl, topic, message, options) { + const user = await userManager.get(baseUrl); + console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); + const headers = {}; + const body = { + topic, + message, + ...options, + }; + await fetchOrThrow(baseUrl, { + method: "PUT", + body: JSON.stringify(body), + headers: maybeWithAuth(headers, user), + }); + } - /** - * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. - * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. - * - * Firefox XHR bug: - * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, - * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the - * correct headers are clearly set. It's quite the odd behavior. - * - * There is an example, and the bug report here: - * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 - * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 - */ - publishXHR(url, body, headers, onProgress) { - console.log(`[Api] Publishing message to ${url}`); - const xhr = new XMLHttpRequest(); - const send = new Promise(function (resolve, reject) { - xhr.open("PUT", url); - if (body.type) { - xhr.overrideMimeType(body.type); + /** + * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. + * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. + * + * Firefox XHR bug: + * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, + * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the + * correct headers are clearly set. It's quite the odd behavior. + * + * There is an example, and the bug report here: + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 + * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 + */ + publishXHR(url, body, headers, onProgress) { + console.log(`[Api] Publishing message to ${url}`); + const xhr = new XMLHttpRequest(); + const send = new Promise((resolve, reject) => { + xhr.open("PUT", url); + if (body.type) { + xhr.overrideMimeType(body.type); + } + for (const [key, value] of Object.entries(headers)) { + xhr.setRequestHeader(key, value); + } + xhr.upload.addEventListener("progress", onProgress); + xhr.addEventListener("readystatechange", () => { + if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { + console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); + resolve(xhr.response); + } else if (xhr.readyState === 4) { + // Firefox bug; see description above! + console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); + let errorText; + try { + const error = JSON.parse(xhr.responseText); + if (error.code && error.error) { + errorText = `Error ${error.code}: ${error.error}`; } - for (const [key, value] of Object.entries(headers)) { - xhr.setRequestHeader(key, value); - } - xhr.upload.addEventListener("progress", onProgress); - xhr.addEventListener('readystatechange', (ev) => { - if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { - console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); - resolve(xhr.response); - } else if (xhr.readyState === 4) { - // Firefox bug; see description above! - console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); - let errorText; - try { - const error = JSON.parse(xhr.responseText); - if (error.code && error.error) { - errorText = `Error ${error.code}: ${error.error}`; - } - } catch (e) { - // Nothing - } - xhr.abort(); - reject(errorText ?? "An error occurred"); - } - }) - xhr.send(body); - }); - send.abort = () => { - console.log(`[Api] Publish aborted by user`); - xhr.abort(); + } catch (e) { + // Nothing + } + xhr.abort(); + reject(errorText ?? "An error occurred"); } - return send; - } + }); + xhr.send(body); + }); + send.abort = () => { + console.log(`[Api] Publish aborted by user`); + xhr.abort(); + }; + return send; + } - async auth(baseUrl, topic, user) { - const url = topicUrlAuth(baseUrl, topic); - console.log(`[Api] Checking auth for ${url}`); - const response = await fetch(url, { - headers: maybeWithBasicAuth({}, user) - }); - if (response.status >= 200 && response.status <= 299) { - return true; - } else if (!user && response.status === 404) { - return true; // Special case: Anonymous login to old servers return 404 since //auth doesn't exist - } else if (response.status === 401 || response.status === 403) { // See server/server.go - return false; - } - throw new Error(`Unexpected server response ${response.status}`); + async topicAuth(baseUrl, topic, user) { + const url = topicUrlAuth(baseUrl, topic); + console.log(`[Api] Checking auth for ${url}`); + const response = await fetch(url, { + headers: maybeWithAuth({}, user), + }); + if (response.status >= 200 && response.status <= 299) { + return true; } + if (response.status === 401 || response.status === 403) { + // See server/server.go + return false; + } + throw new Error(`Unexpected server response ${response.status}`); + } - async userStats(baseUrl) { - const url = userStatsUrl(baseUrl); - console.log(`[Api] Fetching user stats ${url}`); - const response = await fetch(url); - if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - const stats = await response.json(); - console.log(`[Api] Stats`, stats); - return stats; - } + async updateWebPush(pushSubscription, topics) { + const user = await userManager.get(config.base_url); + const url = webPushUrl(config.base_url); + console.log(`[Api] Updating Web Push subscription`, { url, topics, endpoint: pushSubscription.endpoint }); + const serializedSubscription = JSON.parse(JSON.stringify(pushSubscription)); // Ugh ... https://stackoverflow.com/a/40525434/1440785 + await fetchOrThrow(url, { + method: "POST", + headers: maybeWithAuth({}, user), + body: JSON.stringify({ + endpoint: serializedSubscription.endpoint, + auth: serializedSubscription.keys.auth, + p256dh: serializedSubscription.keys.p256dh, + topics, + }), + }); + } + + async deleteWebPush(pushSubscription) { + const user = await userManager.get(config.base_url); + const url = webPushUrl(config.base_url); + console.log(`[Api] Deleting Web Push subscription`, { url, endpoint: pushSubscription.endpoint }); + await fetchOrThrow(url, { + method: "DELETE", + headers: maybeWithAuth({}, user), + body: JSON.stringify({ + endpoint: pushSubscription.endpoint, + }), + }); + } } const api = new Api(); diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 55778023..5358cdde 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,6 +1,13 @@ -import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; +/* eslint-disable max-classes-per-file */ +import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; -const retryBackoffSeconds = [5, 10, 15, 20, 30]; +const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; + +export class ConnectionState { + static Connected = "connected"; + + static Connecting = "connecting"; +} /** * A connection contains a single WebSocket connection for one topic. It handles its connection @@ -9,104 +16,103 @@ const retryBackoffSeconds = [5, 10, 15, 20, 30]; * Incoming messages and state changes are forwarded via listeners. */ class Connection { - constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { - this.connectionId = connectionId; - this.subscriptionId = subscriptionId; - this.baseUrl = baseUrl; - this.topic = topic; - this.user = user; - this.since = since; - this.shortUrl = topicShortUrl(baseUrl, topic); - this.onNotification = onNotification; - this.onStateChanged = onStateChanged; + constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { + this.connectionId = connectionId; + this.subscriptionId = subscriptionId; + this.baseUrl = baseUrl; + this.topic = topic; + this.user = user; + this.since = since; + this.shortUrl = topicShortUrl(baseUrl, topic); + this.onNotification = onNotification; + this.onStateChanged = onStateChanged; + this.ws = null; + this.retryCount = 0; + this.retryTimeout = null; + } + + start() { + // Don't fetch old messages; we do that as a poll() when adding a subscription; + // we don't want to re-trigger the main view re-render potentially hundreds of times. + + const wsUrl = this.wsUrl(); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); + + this.ws = new WebSocket(wsUrl); + this.ws.onopen = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); + this.retryCount = 0; + this.onStateChanged(this.subscriptionId, ConnectionState.Connected); + }; + this.ws.onmessage = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); + try { + const data = JSON.parse(event.data); + if (data.event === "open") { + return; + } + const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; + if (!relevantAndValid) { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); + return; + } + this.since = data.id; + this.onNotification(this.subscriptionId, data); + } catch (e) { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); + } + }; + this.ws.onclose = (event) => { + if (event.wasClean) { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}` + ); this.ws = null; - this.retryCount = 0; - this.retryTimeout = null; + } else { + const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; + this.retryCount += 1; + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); + this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); + this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); + } + }; + this.ws.onerror = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); + }; + } + + close() { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); + const socket = this.ws; + const { retryTimeout } = this; + if (socket !== null) { + socket.close(); } - - start() { - // Don't fetch old messages; we do that as a poll() when adding a subscription; - // we don't want to re-trigger the main view re-render potentially hundreds of times. - - const wsUrl = this.wsUrl(); - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); - - this.ws = new WebSocket(wsUrl); - this.ws.onopen = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); - this.retryCount = 0; - this.onStateChanged(this.subscriptionId, ConnectionState.Connected); - } - this.ws.onmessage = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); - try { - const data = JSON.parse(event.data); - if (data.event === 'open') { - return; - } - const relevantAndValid = - data.event === 'message' && - 'id' in data && - 'time' in data && - 'message' in data; - if (!relevantAndValid) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); - return; - } - this.since = data.id; - this.onNotification(this.subscriptionId, data); - } catch (e) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); - } - }; - this.ws.onclose = (event) => { - if (event.wasClean) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); - this.ws = null; - } else { - const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)]; - this.retryCount++; - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); - this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); - this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); - } - }; - this.ws.onerror = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); - }; + if (retryTimeout !== null) { + clearTimeout(retryTimeout); } + this.retryTimeout = null; + this.ws = null; + } - close() { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); - const socket = this.ws; - const retryTimeout = this.retryTimeout; - if (socket !== null) { - socket.close(); - } - if (retryTimeout !== null) { - clearTimeout(retryTimeout); - } - this.retryTimeout = null; - this.ws = null; + wsUrl() { + const params = []; + if (this.since) { + params.push(`since=${this.since}`); } - - wsUrl() { - const params = []; - if (this.since) { - params.push(`since=${this.since}`); - } - if (this.user) { - const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password)); - params.push(`auth=${auth}`); - } - const wsUrl = topicUrlWs(this.baseUrl, this.topic); - return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; + if (this.user) { + params.push(`auth=${this.authParam()}`); } -} + const wsUrl = topicUrlWs(this.baseUrl, this.topic); + return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`; + } -export class ConnectionState { - static Connected = "connected"; - static Connecting = "connecting"; + authParam() { + if (this.user.password) { + return encodeBase64Url(basicAuth(this.user.username, this.user.password)); + } + return encodeBase64Url(bearerAuth(this.user.token)); + } } export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 565cfe9b..32ffe807 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,5 +1,8 @@ import Connection from "./Connection"; -import {hashCode} from "./utils"; +import { hashCode } from "./utils"; + +const makeConnectionId = (subscription, user) => + user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); /** * The connection manager keeps track of active connections (WebSocket connections, see Connection). @@ -8,109 +11,105 @@ import {hashCode} from "./utils"; * as required. This is done pretty much exactly the same way as in the Android app. */ class ConnectionManager { - constructor() { - this.connections = new Map(); // ConnectionId -> Connection (hash, see below) - this.stateListener = null; // Fired when connection state changes - this.notificationListener = null; // Fired when new notifications arrive + constructor() { + this.connections = new Map(); // ConnectionId -> Connection (hash, see below) + this.stateListener = null; // Fired when connection state changes + this.messageListener = null; // Fired when new notifications arrive + } + + registerStateListener(listener) { + this.stateListener = listener; + } + + resetStateListener() { + this.stateListener = null; + } + + registerMessageListener(listener) { + this.messageListener = listener; + } + + resetMessageListener() { + this.messageListener = null; + } + + /** + * This function figures out which websocket connections should be running by comparing the + * current state of the world (connections) with the target state (targetIds). + * + * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify + * connections. If any of them change, the connection is closed/replaced. + */ + async refresh(subscriptions, users) { + if (!subscriptions || !users) { + return; } + console.log(`[ConnectionManager] Refreshing connections`); + const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => { + const [user] = users.filter((u) => u.baseUrl === s.baseUrl); + const connectionId = makeConnectionId(s, user); + return { ...s, user, connectionId }; + }); - registerStateListener(listener) { - this.stateListener = listener; + const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); + const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id)); + + // Create and add new connections + subscriptionsWithUsersAndConnectionId.forEach((subscription) => { + const subscriptionId = subscription.id; + const { connectionId } = subscription; + const added = !this.connections.get(connectionId); + if (added) { + const { baseUrl, topic, user } = subscription; + const since = subscription.last; + const connection = new Connection( + connectionId, + subscriptionId, + baseUrl, + topic, + user, + since, + (subId, notification) => this.notificationReceived(subId, notification), + (subId, state) => this.stateChanged(subId, state) + ); + this.connections.set(connectionId, connection); + console.log( + `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ + user ? user.username : "anonymous" + })` + ); + connection.start(); + } + }); + + // Delete old connections + deletedIds.forEach((id) => { + console.log(`[ConnectionManager] Closing connection ${id}`); + const connection = this.connections.get(id); + this.connections.delete(id); + connection.close(); + }); + } + + stateChanged(subscriptionId, state) { + if (this.stateListener) { + try { + this.stateListener(subscriptionId, state); + } catch (e) { + console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); + } } + } - resetStateListener() { - this.stateListener = null; + notificationReceived(subscriptionId, notification) { + if (this.messageListener) { + try { + this.messageListener(subscriptionId, notification); + } catch (e) { + console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); + } } - - registerNotificationListener(listener) { - this.notificationListener = listener; - } - - resetNotificationListener() { - this.notificationListener = null; - } - - /** - * This function figures out which websocket connections should be running by comparing the - * current state of the world (connections) with the target state (targetIds). - * - * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify - * connections. If any of them change, the connection is closed/replaced. - */ - async refresh(subscriptions, users) { - if (!subscriptions || !users) { - return; - } - console.log(`[ConnectionManager] Refreshing connections`); - const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions - .map(async s => { - const [user] = users.filter(u => u.baseUrl === s.baseUrl); - const connectionId = await makeConnectionId(s, user); - return {...s, user, connectionId}; - })); - const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId); - const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id)); - - // Create and add new connections - subscriptionsWithUsersAndConnectionId.forEach(subscription => { - const subscriptionId = subscription.id; - const connectionId = subscription.connectionId; - const added = !this.connections.get(connectionId) - if (added) { - const baseUrl = subscription.baseUrl; - const topic = subscription.topic; - const user = subscription.user; - const since = subscription.last; - const connection = new Connection( - connectionId, - subscriptionId, - baseUrl, - topic, - user, - since, - (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), - (subscriptionId, state) => this.stateChanged(subscriptionId, state) - ); - this.connections.set(connectionId, connection); - console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`); - connection.start(); - } - }); - - // Delete old connections - deletedIds.forEach(id => { - console.log(`[ConnectionManager] Closing connection ${id}`); - const connection = this.connections.get(id); - this.connections.delete(id); - connection.close(); - }); - } - - stateChanged(subscriptionId, state) { - if (this.stateListener) { - try { - this.stateListener(subscriptionId, state); - } catch (e) { - console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); - } - } - } - - notificationReceived(subscriptionId, notification) { - if (this.notificationListener) { - try { - this.notificationListener(subscriptionId, notification); - } catch (e) { - console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); - } - } - } -} - -const makeConnectionId = async (subscription, user) => { - return (user) - ? hashCode(`${subscription.id}|${user.username}|${user.password}`) - : hashCode(`${subscription.id}`); + } } const connectionManager = new ConnectionManager(); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 3c4a5e0d..77bbdb1e 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,81 +1,141 @@ -import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicShortUrl} from "./utils"; +import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; +import { toNotificationParams } from "./notificationUtils"; import prefs from "./Prefs"; -import subscriptionManager from "./SubscriptionManager"; -import logo from "../img/ntfy.png"; +import routes from "../components/routes"; /** * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { - async notify(subscriptionId, notification, onClickFallback) { - if (!this.supported()) { - return; - } - const subscription = await subscriptionManager.get(subscriptionId); - const shouldNotify = await this.shouldNotify(subscription, notification); - if (!shouldNotify) { - return; - } - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - const message = formatMessage(notification); - const title = formatTitleWithDefault(notification, shortUrl); - - // Show notification - console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); - const n = new Notification(title, { - body: message, - icon: logo - }); - if (notification.click) { - n.onclick = (e) => openUrl(notification.click); - } else { - n.onclick = () => onClickFallback(subscription); - } - - // Play sound - const sound = await prefs.sound(); - if (sound && sound !== "none") { - try { - await playSound(sound); - } catch (e) { - console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); - } - } + async notify(subscription, notification) { + if (!this.supported()) { + return; } - granted() { - return this.supported() && Notification.permission === 'granted'; + await this.playSound(); + + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + const defaultTitle = topicDisplayName(subscription); + + console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}`); + + const registration = await this.serviceWorkerRegistration(); + await registration.showNotification( + ...toNotificationParams({ + subscriptionId: subscription.id, + message: notification, + defaultTitle, + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), + }) + ); + } + + async playSound() { + // Play sound + const sound = await prefs.sound(); + if (sound && sound !== "none") { + try { + await playSound(sound); + } catch (e) { + console.log(`[Notifier] Error playing audio`, e); + } + } + } + + async webPushSubscription(hasWebPushTopics) { + const pushManager = await this.pushManager(); + const existingSubscription = await pushManager.getSubscription(); + if (existingSubscription) { + return existingSubscription; } - maybeRequestPermission(cb) { - if (!this.supported()) { - cb(false); - return; - } - if (!this.granted()) { - Notification.requestPermission().then((permission) => { - const granted = permission === 'granted'; - cb(granted); - }); - } + // Create a new subscription only if there are new topics to subscribe to. It is possible that Web Push + // was previously enabled and then disabled again in which case there would be an existingSubscription. + // If, however, it was _not_ enabled previously, we create a new subscription if it is now enabled. + + if (hasWebPushTopics) { + return pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), + }); } - async shouldNotify(subscription, notification) { - if (subscription.mutedUntil === 1) { - return false; - } - const priority = (notification.priority) ? notification.priority : 3; - const minPriority = await prefs.minPriority(); - if (priority < minPriority) { - return false; - } - return true; + return undefined; + } + + async pushManager() { + return (await this.serviceWorkerRegistration()).pushManager; + } + + async serviceWorkerRegistration() { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) { + throw new Error("No service worker registration found"); + } + return registration; + } + + notRequested() { + return this.supported() && Notification.permission === "default"; + } + + granted() { + return this.supported() && Notification.permission === "granted"; + } + + denied() { + return this.supported() && Notification.permission === "denied"; + } + + async maybeRequestPermission() { + if (!this.supported()) { + return false; } - supported() { - return 'Notification' in window; - } + return new Promise((resolve) => { + Notification.requestPermission((permission) => { + resolve(permission === "granted"); + }); + }); + } + + supported() { + return this.browserSupported() && this.contextSupported(); + } + + browserSupported() { + return "Notification" in window; + } + + pushSupported() { + return config.enable_web_push && "serviceWorker" in navigator && "PushManager" in window; + } + + pushPossible() { + return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired(); + } + + /** + * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API + * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification + */ + contextSupported() { + return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost"; + } + + // no PushManager when not installed, but it _is_ supported. + iosSupportedButInstallRequired() { + return ( + config.enable_web_push && + // a service worker exists + "serviceWorker" in navigator && + // but the pushmanager API is missing, which implies we're on an iOS device without installing + !("PushManager" in window) && + // check that this is the case by checking for `standalone`, which only exists on Safari + window.navigator.standalone === false + ); + } } const notifier = new Notifier(); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index ec284eaf..2261dddc 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,58 +1,64 @@ import api from "./Api"; import subscriptionManager from "./SubscriptionManager"; -const delayMillis = 8000; // 8 seconds +const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes class Poller { - constructor() { - this.timer = null; - } + constructor() { + this.timer = null; + } - startWorker() { - if (this.timer !== null) { - return; + startWorker() { + if (this.timer !== null) { + return; + } + console.log(`[Poller] Starting worker`); + this.timer = setInterval(() => this.pollAll(), intervalMillis); + setTimeout(() => this.pollAll(), delayMillis); + } + + stopWorker() { + clearTimeout(this.timer); + } + + async pollAll() { + console.log(`[Poller] Polling all subscriptions`); + const subscriptions = await subscriptionManager.all(); + + await Promise.all( + subscriptions.map(async (s) => { + try { + await this.poll(s); + } catch (e) { + console.log(`[Poller] Error polling ${s.id}`, e); } - console.log(`[Poller] Starting worker`); - this.timer = setInterval(() => this.pollAll(), intervalMillis); - setTimeout(() => this.pollAll(), delayMillis); - } + }) + ); + } - async pollAll() { - console.log(`[Poller] Polling all subscriptions`); - const subscriptions = await subscriptionManager.all(); - for (const s of subscriptions) { - try { - await this.poll(s); - } catch (e) { - console.log(`[Poller] Error polling ${s.id}`, e); - } - } - } + async poll(subscription) { + console.log(`[Poller] Polling ${subscription.id}`); - async poll(subscription) { - console.log(`[Poller] Polling ${subscription.id}`); - - const since = subscription.last; - const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - if (!notifications || notifications.length === 0) { - console.log(`[Poller] No new notifications found for ${subscription.id}`); - return; - } - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); + const since = subscription.last; + const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); + if (!notifications || notifications.length === 0) { + console.log(`[Poller] No new notifications found for ${subscription.id}`); + return; } + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notifications); + } - pollInBackground(subscription) { - const fn = async () => { - try { - await this.poll(subscription); - } catch (e) { - console.error(`[App] Error polling subscription ${subscription.id}`, e); - } - }; - setTimeout(() => fn(), 0); - } + pollInBackground(subscription) { + (async () => { + try { + await this.poll(subscription); + } catch (e) { + console.error(`[App] Error polling subscription ${subscription.id}`, e); + } + })(); + } } const poller = new Poller(); diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index b444c6f8..4f28f87e 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -1,33 +1,61 @@ import db from "./db"; +export const THEME = { + DARK: "dark", + LIGHT: "light", + SYSTEM: "system", +}; + class Prefs { - async setSound(sound) { - db.prefs.put({key: 'sound', value: sound.toString()}); - } + constructor(dbImpl) { + this.db = dbImpl; + } - async sound() { - const sound = await db.prefs.get('sound'); - return (sound) ? sound.value : "ding"; - } + async setSound(sound) { + this.db.prefs.put({ key: "sound", value: sound.toString() }); + } - async setMinPriority(minPriority) { - db.prefs.put({key: 'minPriority', value: minPriority.toString()}); - } + async sound() { + const sound = await this.db.prefs.get("sound"); + return sound ? sound.value : "ding"; + } - async minPriority() { - const minPriority = await db.prefs.get('minPriority'); - return (minPriority) ? Number(minPriority.value) : 1; - } + async setMinPriority(minPriority) { + this.db.prefs.put({ key: "minPriority", value: minPriority.toString() }); + } - async setDeleteAfter(deleteAfter) { - db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()}); - } + async minPriority() { + const minPriority = await this.db.prefs.get("minPriority"); + return minPriority ? Number(minPriority.value) : 1; + } - async deleteAfter() { - const deleteAfter = await db.prefs.get('deleteAfter'); - return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week - } + async setDeleteAfter(deleteAfter) { + await this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() }); + } + + async deleteAfter() { + const deleteAfter = await this.db.prefs.get("deleteAfter"); + return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week + } + + async webPushEnabled() { + const webPushEnabled = await this.db.prefs.get("webPushEnabled"); + return webPushEnabled?.value; + } + + async setWebPushEnabled(enabled) { + await this.db.prefs.put({ key: "webPushEnabled", value: enabled }); + } + + async theme() { + const theme = await this.db.prefs.get("theme"); + return theme?.value ?? THEME.SYSTEM; + } + + async setTheme(mode) { + await this.db.prefs.put({ key: "theme", value: mode }); + } } -const prefs = new Prefs(); +const prefs = new Prefs(db()); export default prefs; diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 45948057..f9568a33 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -5,33 +5,37 @@ const delayMillis = 25000; // 25 seconds const intervalMillis = 1800000; // 30 minutes class Pruner { - constructor() { - this.timer = null; - } + constructor() { + this.timer = null; + } - startWorker() { - if (this.timer !== null) { - return; - } - console.log(`[Pruner] Starting worker`); - this.timer = setInterval(() => this.prune(), intervalMillis); - setTimeout(() => this.prune(), delayMillis); + startWorker() { + if (this.timer !== null) { + return; } + console.log(`[Pruner] Starting worker`); + this.timer = setInterval(() => this.prune(), intervalMillis); + setTimeout(() => this.prune(), delayMillis); + } - async prune() { - const deleteAfterSeconds = await prefs.deleteAfter(); - const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds; - if (deleteAfterSeconds === 0) { - console.log(`[Pruner] Pruning is disabled. Skipping.`); - return; - } - console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); - try { - await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); - } catch (e) { - console.log(`[Pruner] Error pruning old subscriptions`, e); - } + stopWorker() { + clearTimeout(this.timer); + } + + async prune() { + const deleteAfterSeconds = await prefs.deleteAfter(); + const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds; + if (deleteAfterSeconds === 0) { + console.log(`[Pruner] Pruning is disabled. Skipping.`); + return; } + console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); + try { + await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); + } catch (e) { + console.log(`[Pruner] Error pruning old subscriptions`, e); + } + } } const pruner = new Pruner(); diff --git a/web/src/app/Session.js b/web/src/app/Session.js new file mode 100644 index 00000000..7464150c --- /dev/null +++ b/web/src/app/Session.js @@ -0,0 +1,69 @@ +import Dexie from "dexie"; + +/** + * Manages the logged-in user's session and access token. + * The session replica is stored in IndexedDB so that the service worker can access it. + */ +class Session { + constructor() { + const db = new Dexie("session-replica"); + db.version(1).stores({ + kv: "&key", + }); + this.db = db; + + // existing sessions (pre-v2.6.0) haven't called `store` with the session-replica, + // so attempt to sync any values from localStorage to IndexedDB + if (typeof localStorage !== "undefined" && this.exists()) { + const username = this.username(); + const token = this.token(); + + this.db.kv + .bulkPut([ + { key: "user", value: username }, + { key: "token", value: token }, + ]) + .then(() => { + console.log("[Session] Synced localStorage session to IndexedDB", { username }); + }) + .catch((e) => { + console.error("[Session] Failed to sync localStorage session to IndexedDB", e); + }); + } + } + + async store(username, token) { + await this.db.kv.bulkPut([ + { key: "user", value: username }, + { key: "token", value: token }, + ]); + localStorage.setItem("user", username); + localStorage.setItem("token", token); + } + + async resetAndRedirect(url) { + await this.db.delete(); + localStorage.removeItem("user"); + localStorage.removeItem("token"); + window.location.href = url; + } + + async usernameAsync() { + return (await this.db.kv.get({ key: "user" }))?.value; + } + + exists() { + return this.username() && this.token(); + } + + username() { + return localStorage.getItem("user"); + } + + token() { + return localStorage.getItem("token"); + } +} + +const session = new Session(); +export default session; diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index b485383d..de99b642 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -1,138 +1,262 @@ +import api from "./Api"; +import notifier from "./Notifier"; +import prefs from "./Prefs"; import db from "./db"; -import {topicUrl} from "./utils"; +import { topicUrl } from "./utils"; class SubscriptionManager { - /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ - async all() { - const subscriptions = await db.subscriptions.toArray(); - await Promise.all(subscriptions.map(async s => { - s.new = await db.notifications - .where({ subscriptionId: s.id, new: 1 }) - .count(); - })); - return subscriptions; + constructor(dbImpl) { + this.db = dbImpl; + } + + /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ + async all() { + const subscriptions = await this.db.subscriptions.toArray(); + return Promise.all( + subscriptions.map(async (s) => ({ + ...s, + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + })) + ); + } + + /** + * List of topics for which Web Push is enabled. This excludes (a) internal topics, (b) topics that are muted, + * and (c) topics from other hosts. Returns an empty list if Web Push is disabled. + * + * It is important to note that "mutedUntil" must be part of the where() query, otherwise the Dexie live query + * will not react to it, and the Web Push topics will not be updated when the user mutes a topic. + */ + async webPushTopics(pushPossible) { + if (!pushPossible) { + return []; } - async get(subscriptionId) { - return await db.subscriptions.get(subscriptionId) + // the Promise.resolve wrapper is not superfluous, without it the live query breaks: + // https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier + const enabled = await Promise.resolve(prefs.webPushEnabled()); + if (!enabled) { + return []; } - async add(baseUrl, topic) { - const subscription = { - id: topicUrl(baseUrl, topic), - baseUrl: baseUrl, - topic: topic, - mutedUntil: 0, - last: null - }; - await db.subscriptions.put(subscription); - return subscription; + const subscriptions = await this.db.subscriptions.where({ baseUrl: config.base_url, mutedUntil: 0 }).toArray(); + return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic); + } + + async get(subscriptionId) { + return this.db.subscriptions.get(subscriptionId); + } + + async notify(subscriptionId, notification) { + const subscription = await this.get(subscriptionId); + if (subscription.mutedUntil > 0) { + return; } - async updateState(subscriptionId, state) { - db.subscriptions.update(subscriptionId, { state: state }); + const priority = notification.priority ?? 3; + if (priority < (await prefs.minPriority())) { + return; } - async remove(subscriptionId) { - await db.subscriptions.delete(subscriptionId); - await db.notifications - .where({subscriptionId: subscriptionId}) - .delete(); + await notifier.notify(subscription, notification); + } + + /** + * @param {string} baseUrl + * @param {string} topic + * @param {object} opts + * @param {boolean} opts.internal + * @returns + */ + async add(baseUrl, topic, opts = {}) { + const id = topicUrl(baseUrl, topic); + + const existingSubscription = await this.get(id); + if (existingSubscription) { + return existingSubscription; } - async first() { - return db.subscriptions.toCollection().first(); // May be undefined - } + const subscription = { + ...opts, + id: topicUrl(baseUrl, topic), + baseUrl, + topic, + mutedUntil: 0, + last: null, + }; - async getNotifications(subscriptionId) { - // This is quite awkward, but it is the recommended approach as per the Dexie docs. - // It's actually fine, because the reading and filtering is quite fast. The rendering is what's - // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach + await this.db.subscriptions.put(subscription); - return db.notifications - .orderBy("time") // Sort by time first - .filter(n => n.subscriptionId === subscriptionId) - .reverse() - .toArray(); - } + return subscription; + } - async getAllNotifications() { - return db.notifications - .orderBy("time") // Efficient, see docs - .reverse() - .toArray(); - } + async syncFromRemote(remoteSubscriptions, remoteReservations) { + console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); - /** Adds notification, or returns false if it already exists */ - async addNotification(subscriptionId, notification) { - const exists = await db.notifications.get(notification.id); - if (exists) { - return false; - } - try { - notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab - await db.subscriptions.update(subscriptionId, { - last: notification.id - }); - } catch (e) { - console.error(`[SubscriptionManager] Error adding notification`, e); - } - return true; - } + // Add remote subscriptions + const remoteIds = await Promise.all( + remoteSubscriptions.map(async (remote) => { + const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; - /** Adds/replaces notifications, will not throw if they exist */ - async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications - .map(notification => ({ ...notification, subscriptionId })); - const lastNotificationId = notifications.at(-1).id; - await db.notifications.bulkPut(notificationsWithSubscriptionId); - await db.subscriptions.update(subscriptionId, { - last: lastNotificationId + const local = await this.add(remote.base_url, remote.topic, { + displayName: remote.display_name, // May be undefined + reservation, // May be null! }); - } - async updateNotification(notification) { - const exists = await db.notifications.get(notification.id); - if (!exists) { - return false; + return local.id; + }) + ); + + // Remove local subscriptions that do not exist remotely + const localSubscriptions = await this.db.subscriptions.toArray(); + + await Promise.all( + localSubscriptions.map(async (local) => { + const remoteExists = remoteIds.includes(local.id); + if (!local.internal && !remoteExists) { + await this.remove(local); } - try { - await db.notifications.put({ ...notification }); - } catch (e) { - console.error(`[SubscriptionManager] Error updating notification`, e); - } - return true; + }) + ); + } + + async updateWebPushSubscriptions(topics) { + const hasWebPushTopics = topics.length > 0; + const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics); + + if (!browserSubscription) { + console.log( + "[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping." + ); + return; } - async deleteNotification(notificationId) { - await db.notifications.delete(notificationId); + if (hasWebPushTopics) { + await api.updateWebPush(browserSubscription, topics); + } else { + await api.deleteWebPush(browserSubscription); } + } - async deleteNotifications(subscriptionId) { - await db.notifications - .where({subscriptionId: subscriptionId}) - .delete(); - } + async updateState(subscriptionId, state) { + this.db.subscriptions.update(subscriptionId, { state }); + } - async markNotificationsRead(subscriptionId) { - await db.notifications - .where({subscriptionId: subscriptionId, new: 1}) - .modify({new: 0}); - } + async remove(subscription) { + await this.db.subscriptions.delete(subscription.id); + await this.db.notifications.where({ subscriptionId: subscription.id }).delete(); + } - async setMutedUntil(subscriptionId, mutedUntil) { - await db.subscriptions.update(subscriptionId, { - mutedUntil: mutedUntil - }); - } + async first() { + return this.db.subscriptions.toCollection().first(); // May be undefined + } - async pruneNotifications(thresholdTimestamp) { - await db.notifications - .where("time").below(thresholdTimestamp) - .delete(); + async getNotifications(subscriptionId) { + // This is quite awkward, but it is the recommended approach as per the Dexie docs. + // It's actually fine, because the reading and filtering is quite fast. The rendering is what's + // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach + + return this.db.notifications + .orderBy("time") // Sort by time first + .filter((n) => n.subscriptionId === subscriptionId) + .reverse() + .toArray(); + } + + async getAllNotifications() { + return this.db.notifications + .orderBy("time") // Efficient, see docs + .reverse() + .toArray(); + } + + /** Adds notification, or returns false if it already exists */ + async addNotification(subscriptionId, notification) { + const exists = await this.db.notifications.get(notification.id); + if (exists) { + return false; } + try { + // sw.js duplicates this logic, so if you change it here, change it there too + await this.db.notifications.add({ + ...notification, + subscriptionId, + // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, + }); // FIXME consider put() for double tab + await this.db.subscriptions.update(subscriptionId, { + last: notification.id, + }); + } catch (e) { + console.error(`[SubscriptionManager] Error adding notification`, e); + } + return true; + } + + /** Adds/replaces notifications, will not throw if they exist */ + async addNotifications(subscriptionId, notifications) { + const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId })); + const lastNotificationId = notifications.at(-1).id; + await this.db.notifications.bulkPut(notificationsWithSubscriptionId); + await this.db.subscriptions.update(subscriptionId, { + last: lastNotificationId, + }); + } + + async updateNotification(notification) { + const exists = await this.db.notifications.get(notification.id); + if (!exists) { + return false; + } + try { + await this.db.notifications.put({ ...notification }); + } catch (e) { + console.error(`[SubscriptionManager] Error updating notification`, e); + } + return true; + } + + async deleteNotification(notificationId) { + await this.db.notifications.delete(notificationId); + } + + async deleteNotifications(subscriptionId) { + await this.db.notifications.where({ subscriptionId }).delete(); + } + + async markNotificationRead(notificationId) { + await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); + } + + async markNotificationsRead(subscriptionId) { + await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 }); + } + + async setMutedUntil(subscriptionId, mutedUntil) { + await this.db.subscriptions.update(subscriptionId, { + mutedUntil, + }); + } + + async setDisplayName(subscriptionId, displayName) { + await this.db.subscriptions.update(subscriptionId, { + displayName, + }); + } + + async setReservation(subscriptionId, reservation) { + await this.db.subscriptions.update(subscriptionId, { + reservation, + }); + } + + async update(subscriptionId, params) { + await this.db.subscriptions.update(subscriptionId, params); + } + + async pruneNotifications(thresholdTimestamp) { + await this.db.notifications.where("time").below(thresholdTimestamp).delete(); + } } -const subscriptionManager = new SubscriptionManager(); -export default subscriptionManager; +export default new SubscriptionManager(db()); diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index 25ad41e3..b53b1da8 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -1,22 +1,50 @@ import db from "./db"; +import session from "./Session"; class UserManager { - async all() { - return db.users.toArray(); - } + constructor(dbImpl) { + this.db = dbImpl; + } - async get(baseUrl) { - return db.users.get(baseUrl); + async all() { + const users = await this.db.users.toArray(); + if (session.exists()) { + users.unshift(this.localUser()); } + return users; + } - async save(user) { - await db.users.put(user); + async get(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return this.localUser(); } + return this.db.users.get(baseUrl); + } - async delete(baseUrl) { - await db.users.delete(baseUrl); + async save(user) { + if (session.exists() && user.baseUrl === config.base_url) { + return; } + await this.db.users.put(user); + } + + async delete(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return; + } + await this.db.users.delete(baseUrl); + } + + localUser() { + if (!session.exists()) { + return null; + } + return { + baseUrl: config.base_url, + username: session.username(), + token: session.token(), // Not "password"! + }; + } } -const userManager = new UserManager(); -export default userManager; +export default new UserManager(db()); diff --git a/web/src/app/config.js b/web/src/app/config.js index 71a9ece3..24e86f3a 100644 --- a/web/src/app/config.js +++ b/web/src/app/config.js @@ -1,2 +1,9 @@ -const config = window.config; +const { config } = window; + +// The backend returns an empty base_url for the config struct, +// so the frontend (hey, that's us!) can use the current location. +if (!config.base_url || config.base_url === "") { + config.base_url = window.location.origin; +} + export default config; diff --git a/web/src/app/db.js b/web/src/app/db.js index 7c82be31..b28fb716 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -1,4 +1,5 @@ -import Dexie from 'dexie'; +import Dexie from "dexie"; +import session from "./Session"; // Uses Dexie.js // https://dexie.org/docs/API-Reference#quick-reference @@ -6,13 +7,25 @@ import Dexie from 'dexie'; // Notes: // - As per docs, we only declare the indexable columns, not all columns -const db = new Dexie('ntfy'); +const createDatabase = (username) => { + const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user + const db = new Dexie(dbName); -db.version(1).stores({ - subscriptions: '&id,baseUrl', - notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance - users: '&baseUrl,username', - prefs: '&key' -}); + db.version(2).stores({ + subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", + notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance + users: "&baseUrl,username", + prefs: "&key", + }); + + return db; +}; + +export const dbAsync = async () => { + const username = await session.usernameAsync(); + return createDatabase(username); +}; + +const db = () => createDatabase(session.username()); export default db; diff --git a/web/src/app/emojis.js b/web/src/app/emojis.js index f6dac7b1..b7912c35 100644 --- a/web/src/app/emojis.js +++ b/web/src/app/emojis.js @@ -1,3 +1,14500 @@ // This file is generated by scripts/emoji-convert.sh to reduce the size // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json -export const rawEmojis = [{"emoji":"😀","aliases":["grinning"],"tags":["smile","happy"],"category":"Smileys & Emotion","description":"grinning face","unicode_version":"6.1"},{"emoji":"😃","aliases":["smiley"],"tags":["happy","joy","haha"],"category":"Smileys & Emotion","description":"grinning face with big eyes","unicode_version":"6.0"},{"emoji":"😄","aliases":["smile"],"tags":["happy","joy","laugh","pleased"],"category":"Smileys & Emotion","description":"grinning face with smiling eyes","unicode_version":"6.0"},{"emoji":"😁","aliases":["grin"],"tags":[],"category":"Smileys & Emotion","description":"beaming face with smiling eyes","unicode_version":"6.0"},{"emoji":"😆","aliases":["laughing","satisfied"],"tags":["happy","haha"],"category":"Smileys & Emotion","description":"grinning squinting face","unicode_version":"6.0"},{"emoji":"😅","aliases":["sweat_smile"],"tags":["hot"],"category":"Smileys & Emotion","description":"grinning face with sweat","unicode_version":"6.0"},{"emoji":"🤣","aliases":["rofl"],"tags":["lol","laughing"],"category":"Smileys & Emotion","description":"rolling on the floor laughing","unicode_version":"9.0"},{"emoji":"😂","aliases":["joy"],"tags":["tears"],"category":"Smileys & Emotion","description":"face with tears of joy","unicode_version":"6.0"},{"emoji":"🙂","aliases":["slightly_smiling_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly smiling face","unicode_version":"7.0"},{"emoji":"🙃","aliases":["upside_down_face"],"tags":[],"category":"Smileys & Emotion","description":"upside-down face","unicode_version":"8.0"},{"emoji":"😉","aliases":["wink"],"tags":["flirt"],"category":"Smileys & Emotion","description":"winking face","unicode_version":"6.0"},{"emoji":"😊","aliases":["blush"],"tags":["proud"],"category":"Smileys & Emotion","description":"smiling face with smiling eyes","unicode_version":"6.0"},{"emoji":"😇","aliases":["innocent"],"tags":["angel"],"category":"Smileys & Emotion","description":"smiling face with halo","unicode_version":"6.0"},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"],"tags":["love"],"category":"Smileys & Emotion","description":"smiling face with hearts","unicode_version":"11.0"},{"emoji":"😍","aliases":["heart_eyes"],"tags":["love","crush"],"category":"Smileys & Emotion","description":"smiling face with heart-eyes","unicode_version":"6.0"},{"emoji":"🤩","aliases":["star_struck"],"tags":["eyes"],"category":"Smileys & Emotion","description":"star-struck","unicode_version":"11.0"},{"emoji":"😘","aliases":["kissing_heart"],"tags":["flirt"],"category":"Smileys & Emotion","description":"face blowing a kiss","unicode_version":"6.0"},{"emoji":"😗","aliases":["kissing"],"tags":[],"category":"Smileys & Emotion","description":"kissing face","unicode_version":"6.1"},{"emoji":"☺️","aliases":["relaxed"],"tags":["blush","pleased"],"category":"Smileys & Emotion","description":"smiling face","unicode_version":""},{"emoji":"😚","aliases":["kissing_closed_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with closed eyes","unicode_version":"6.0"},{"emoji":"😙","aliases":["kissing_smiling_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with smiling eyes","unicode_version":"6.1"},{"emoji":"🥲","aliases":["smiling_face_with_tear"],"tags":[],"category":"Smileys & Emotion","description":"smiling face with tear","unicode_version":"13.0"},{"emoji":"😋","aliases":["yum"],"tags":["tongue","lick"],"category":"Smileys & Emotion","description":"face savoring food","unicode_version":"6.0"},{"emoji":"😛","aliases":["stuck_out_tongue"],"tags":[],"category":"Smileys & Emotion","description":"face with tongue","unicode_version":"6.1"},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"],"tags":["prank","silly"],"category":"Smileys & Emotion","description":"winking face with tongue","unicode_version":"6.0"},{"emoji":"🤪","aliases":["zany_face"],"tags":["goofy","wacky"],"category":"Smileys & Emotion","description":"zany face","unicode_version":"11.0"},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"],"tags":["prank"],"category":"Smileys & Emotion","description":"squinting face with tongue","unicode_version":"6.0"},{"emoji":"🤑","aliases":["money_mouth_face"],"tags":["rich"],"category":"Smileys & Emotion","description":"money-mouth face","unicode_version":"8.0"},{"emoji":"🤗","aliases":["hugs"],"tags":[],"category":"Smileys & Emotion","description":"hugging face","unicode_version":"8.0"},{"emoji":"🤭","aliases":["hand_over_mouth"],"tags":["quiet","whoops"],"category":"Smileys & Emotion","description":"face with hand over mouth","unicode_version":"11.0"},{"emoji":"🤫","aliases":["shushing_face"],"tags":["silence","quiet"],"category":"Smileys & Emotion","description":"shushing face","unicode_version":"11.0"},{"emoji":"🤔","aliases":["thinking"],"tags":[],"category":"Smileys & Emotion","description":"thinking face","unicode_version":"8.0"},{"emoji":"🤐","aliases":["zipper_mouth_face"],"tags":["silence","hush"],"category":"Smileys & Emotion","description":"zipper-mouth face","unicode_version":"8.0"},{"emoji":"🤨","aliases":["raised_eyebrow"],"tags":["suspicious"],"category":"Smileys & Emotion","description":"face with raised eyebrow","unicode_version":"11.0"},{"emoji":"😐","aliases":["neutral_face"],"tags":["meh"],"category":"Smileys & Emotion","description":"neutral face","unicode_version":"6.0"},{"emoji":"😑","aliases":["expressionless"],"tags":[],"category":"Smileys & Emotion","description":"expressionless face","unicode_version":"6.1"},{"emoji":"😶","aliases":["no_mouth"],"tags":["mute","silence"],"category":"Smileys & Emotion","description":"face without mouth","unicode_version":"6.0"},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"],"tags":[],"category":"Smileys & Emotion","description":"face in clouds","unicode_version":"13.1"},{"emoji":"😏","aliases":["smirk"],"tags":["smug"],"category":"Smileys & Emotion","description":"smirking face","unicode_version":"6.0"},{"emoji":"😒","aliases":["unamused"],"tags":["meh"],"category":"Smileys & Emotion","description":"unamused face","unicode_version":"6.0"},{"emoji":"🙄","aliases":["roll_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with rolling eyes","unicode_version":"8.0"},{"emoji":"😬","aliases":["grimacing"],"tags":[],"category":"Smileys & Emotion","description":"grimacing face","unicode_version":"6.1"},{"emoji":"😮‍💨","aliases":["face_exhaling"],"tags":[],"category":"Smileys & Emotion","description":"face exhaling","unicode_version":"13.1"},{"emoji":"🤥","aliases":["lying_face"],"tags":["liar"],"category":"Smileys & Emotion","description":"lying face","unicode_version":"9.0"},{"emoji":"😌","aliases":["relieved"],"tags":["whew"],"category":"Smileys & Emotion","description":"relieved face","unicode_version":"6.0"},{"emoji":"😔","aliases":["pensive"],"tags":[],"category":"Smileys & Emotion","description":"pensive face","unicode_version":"6.0"},{"emoji":"😪","aliases":["sleepy"],"tags":["tired"],"category":"Smileys & Emotion","description":"sleepy face","unicode_version":"6.0"},{"emoji":"🤤","aliases":["drooling_face"],"tags":[],"category":"Smileys & Emotion","description":"drooling face","unicode_version":"9.0"},{"emoji":"😴","aliases":["sleeping"],"tags":["zzz"],"category":"Smileys & Emotion","description":"sleeping face","unicode_version":"6.1"},{"emoji":"😷","aliases":["mask"],"tags":["sick","ill"],"category":"Smileys & Emotion","description":"face with medical mask","unicode_version":"6.0"},{"emoji":"🤒","aliases":["face_with_thermometer"],"tags":["sick"],"category":"Smileys & Emotion","description":"face with thermometer","unicode_version":"8.0"},{"emoji":"🤕","aliases":["face_with_head_bandage"],"tags":["hurt"],"category":"Smileys & Emotion","description":"face with head-bandage","unicode_version":"8.0"},{"emoji":"🤢","aliases":["nauseated_face"],"tags":["sick","barf","disgusted"],"category":"Smileys & Emotion","description":"nauseated face","unicode_version":"9.0"},{"emoji":"🤮","aliases":["vomiting_face"],"tags":["barf","sick"],"category":"Smileys & Emotion","description":"face vomiting","unicode_version":"11.0"},{"emoji":"🤧","aliases":["sneezing_face"],"tags":["achoo","sick"],"category":"Smileys & Emotion","description":"sneezing face","unicode_version":"9.0"},{"emoji":"🥵","aliases":["hot_face"],"tags":["heat","sweating"],"category":"Smileys & Emotion","description":"hot face","unicode_version":"11.0"},{"emoji":"🥶","aliases":["cold_face"],"tags":["freezing","ice"],"category":"Smileys & Emotion","description":"cold face","unicode_version":"11.0"},{"emoji":"🥴","aliases":["woozy_face"],"tags":["groggy"],"category":"Smileys & Emotion","description":"woozy face","unicode_version":"11.0"},{"emoji":"😵","aliases":["dizzy_face"],"tags":[],"category":"Smileys & Emotion","description":"knocked-out face","unicode_version":"6.0"},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with spiral eyes","unicode_version":"13.1"},{"emoji":"🤯","aliases":["exploding_head"],"tags":["mind","blown"],"category":"Smileys & Emotion","description":"exploding head","unicode_version":"11.0"},{"emoji":"🤠","aliases":["cowboy_hat_face"],"tags":[],"category":"Smileys & Emotion","description":"cowboy hat face","unicode_version":"9.0"},{"emoji":"🥳","aliases":["partying_face"],"tags":["celebration","birthday"],"category":"Smileys & Emotion","description":"partying face","unicode_version":"11.0"},{"emoji":"🥸","aliases":["disguised_face"],"tags":[],"category":"Smileys & Emotion","description":"disguised face","unicode_version":"13.0"},{"emoji":"😎","aliases":["sunglasses"],"tags":["cool"],"category":"Smileys & Emotion","description":"smiling face with sunglasses","unicode_version":"6.0"},{"emoji":"🤓","aliases":["nerd_face"],"tags":["geek","glasses"],"category":"Smileys & Emotion","description":"nerd face","unicode_version":"8.0"},{"emoji":"🧐","aliases":["monocle_face"],"tags":[],"category":"Smileys & Emotion","description":"face with monocle","unicode_version":"11.0"},{"emoji":"😕","aliases":["confused"],"tags":[],"category":"Smileys & Emotion","description":"confused face","unicode_version":"6.1"},{"emoji":"😟","aliases":["worried"],"tags":["nervous"],"category":"Smileys & Emotion","description":"worried face","unicode_version":"6.1"},{"emoji":"🙁","aliases":["slightly_frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly frowning face","unicode_version":"7.0"},{"emoji":"☹️","aliases":["frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"frowning face","unicode_version":""},{"emoji":"😮","aliases":["open_mouth"],"tags":["surprise","impressed","wow"],"category":"Smileys & Emotion","description":"face with open mouth","unicode_version":"6.1"},{"emoji":"😯","aliases":["hushed"],"tags":["silence","speechless"],"category":"Smileys & Emotion","description":"hushed face","unicode_version":"6.1"},{"emoji":"😲","aliases":["astonished"],"tags":["amazed","gasp"],"category":"Smileys & Emotion","description":"astonished face","unicode_version":"6.0"},{"emoji":"😳","aliases":["flushed"],"tags":[],"category":"Smileys & Emotion","description":"flushed face","unicode_version":"6.0"},{"emoji":"🥺","aliases":["pleading_face"],"tags":["puppy","eyes"],"category":"Smileys & Emotion","description":"pleading face","unicode_version":"11.0"},{"emoji":"😦","aliases":["frowning"],"tags":[],"category":"Smileys & Emotion","description":"frowning face with open mouth","unicode_version":"6.1"},{"emoji":"😧","aliases":["anguished"],"tags":["stunned"],"category":"Smileys & Emotion","description":"anguished face","unicode_version":"6.1"},{"emoji":"😨","aliases":["fearful"],"tags":["scared","shocked","oops"],"category":"Smileys & Emotion","description":"fearful face","unicode_version":"6.0"},{"emoji":"😰","aliases":["cold_sweat"],"tags":["nervous"],"category":"Smileys & Emotion","description":"anxious face with sweat","unicode_version":"6.0"},{"emoji":"😥","aliases":["disappointed_relieved"],"tags":["phew","sweat","nervous"],"category":"Smileys & Emotion","description":"sad but relieved face","unicode_version":"6.0"},{"emoji":"😢","aliases":["cry"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying face","unicode_version":"6.0"},{"emoji":"😭","aliases":["sob"],"tags":["sad","cry","bawling"],"category":"Smileys & Emotion","description":"loudly crying face","unicode_version":"6.0"},{"emoji":"😱","aliases":["scream"],"tags":["horror","shocked"],"category":"Smileys & Emotion","description":"face screaming in fear","unicode_version":"6.0"},{"emoji":"😖","aliases":["confounded"],"tags":[],"category":"Smileys & Emotion","description":"confounded face","unicode_version":"6.0"},{"emoji":"😣","aliases":["persevere"],"tags":["struggling"],"category":"Smileys & Emotion","description":"persevering face","unicode_version":"6.0"},{"emoji":"😞","aliases":["disappointed"],"tags":["sad"],"category":"Smileys & Emotion","description":"disappointed face","unicode_version":"6.0"},{"emoji":"😓","aliases":["sweat"],"tags":[],"category":"Smileys & Emotion","description":"downcast face with sweat","unicode_version":"6.0"},{"emoji":"😩","aliases":["weary"],"tags":["tired"],"category":"Smileys & Emotion","description":"weary face","unicode_version":"6.0"},{"emoji":"😫","aliases":["tired_face"],"tags":["upset","whine"],"category":"Smileys & Emotion","description":"tired face","unicode_version":"6.0"},{"emoji":"🥱","aliases":["yawning_face"],"tags":[],"category":"Smileys & Emotion","description":"yawning face","unicode_version":"12.0"},{"emoji":"😤","aliases":["triumph"],"tags":["smug"],"category":"Smileys & Emotion","description":"face with steam from nose","unicode_version":"6.0"},{"emoji":"😡","aliases":["rage","pout"],"tags":["angry"],"category":"Smileys & Emotion","description":"pouting face","unicode_version":"6.0"},{"emoji":"😠","aliases":["angry"],"tags":["mad","annoyed"],"category":"Smileys & Emotion","description":"angry face","unicode_version":"6.0"},{"emoji":"🤬","aliases":["cursing_face"],"tags":["foul"],"category":"Smileys & Emotion","description":"face with symbols on mouth","unicode_version":"11.0"},{"emoji":"😈","aliases":["smiling_imp"],"tags":["devil","evil","horns"],"category":"Smileys & Emotion","description":"smiling face with horns","unicode_version":"6.0"},{"emoji":"👿","aliases":["imp"],"tags":["angry","devil","evil","horns"],"category":"Smileys & Emotion","description":"angry face with horns","unicode_version":"6.0"},{"emoji":"💀","aliases":["skull"],"tags":["dead","danger","poison"],"category":"Smileys & Emotion","description":"skull","unicode_version":"6.0"},{"emoji":"☠️","aliases":["skull_and_crossbones"],"tags":["danger","pirate"],"category":"Smileys & Emotion","description":"skull and crossbones","unicode_version":""},{"emoji":"💩","aliases":["hankey","poop","shit"],"tags":["crap"],"category":"Smileys & Emotion","description":"pile of poo","unicode_version":"6.0"},{"emoji":"🤡","aliases":["clown_face"],"tags":[],"category":"Smileys & Emotion","description":"clown face","unicode_version":"9.0"},{"emoji":"👹","aliases":["japanese_ogre"],"tags":["monster"],"category":"Smileys & Emotion","description":"ogre","unicode_version":"6.0"},{"emoji":"👺","aliases":["japanese_goblin"],"tags":[],"category":"Smileys & Emotion","description":"goblin","unicode_version":"6.0"},{"emoji":"👻","aliases":["ghost"],"tags":["halloween"],"category":"Smileys & Emotion","description":"ghost","unicode_version":"6.0"},{"emoji":"👽","aliases":["alien"],"tags":["ufo"],"category":"Smileys & Emotion","description":"alien","unicode_version":"6.0"},{"emoji":"👾","aliases":["space_invader"],"tags":["game","retro"],"category":"Smileys & Emotion","description":"alien monster","unicode_version":"6.0"},{"emoji":"🤖","aliases":["robot"],"tags":[],"category":"Smileys & Emotion","description":"robot","unicode_version":"8.0"},{"emoji":"😺","aliases":["smiley_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat","unicode_version":"6.0"},{"emoji":"😸","aliases":["smile_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat with smiling eyes","unicode_version":"6.0"},{"emoji":"😹","aliases":["joy_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with tears of joy","unicode_version":"6.0"},{"emoji":"😻","aliases":["heart_eyes_cat"],"tags":[],"category":"Smileys & Emotion","description":"smiling cat with heart-eyes","unicode_version":"6.0"},{"emoji":"😼","aliases":["smirk_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with wry smile","unicode_version":"6.0"},{"emoji":"😽","aliases":["kissing_cat"],"tags":[],"category":"Smileys & Emotion","description":"kissing cat","unicode_version":"6.0"},{"emoji":"🙀","aliases":["scream_cat"],"tags":["horror"],"category":"Smileys & Emotion","description":"weary cat","unicode_version":"6.0"},{"emoji":"😿","aliases":["crying_cat_face"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying cat","unicode_version":"6.0"},{"emoji":"😾","aliases":["pouting_cat"],"tags":[],"category":"Smileys & Emotion","description":"pouting cat","unicode_version":"6.0"},{"emoji":"🙈","aliases":["see_no_evil"],"tags":["monkey","blind","ignore"],"category":"Smileys & Emotion","description":"see-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙉","aliases":["hear_no_evil"],"tags":["monkey","deaf"],"category":"Smileys & Emotion","description":"hear-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙊","aliases":["speak_no_evil"],"tags":["monkey","mute","hush"],"category":"Smileys & Emotion","description":"speak-no-evil monkey","unicode_version":"6.0"},{"emoji":"💋","aliases":["kiss"],"tags":["lipstick"],"category":"Smileys & Emotion","description":"kiss mark","unicode_version":"6.0"},{"emoji":"💌","aliases":["love_letter"],"tags":["email","envelope"],"category":"Smileys & Emotion","description":"love letter","unicode_version":"6.0"},{"emoji":"💘","aliases":["cupid"],"tags":["love","heart"],"category":"Smileys & Emotion","description":"heart with arrow","unicode_version":"6.0"},{"emoji":"💝","aliases":["gift_heart"],"tags":["chocolates"],"category":"Smileys & Emotion","description":"heart with ribbon","unicode_version":"6.0"},{"emoji":"💖","aliases":["sparkling_heart"],"tags":[],"category":"Smileys & Emotion","description":"sparkling heart","unicode_version":"6.0"},{"emoji":"💗","aliases":["heartpulse"],"tags":[],"category":"Smileys & Emotion","description":"growing heart","unicode_version":"6.0"},{"emoji":"💓","aliases":["heartbeat"],"tags":[],"category":"Smileys & Emotion","description":"beating heart","unicode_version":"6.0"},{"emoji":"💞","aliases":["revolving_hearts"],"tags":[],"category":"Smileys & Emotion","description":"revolving hearts","unicode_version":"6.0"},{"emoji":"💕","aliases":["two_hearts"],"tags":[],"category":"Smileys & Emotion","description":"two hearts","unicode_version":"6.0"},{"emoji":"💟","aliases":["heart_decoration"],"tags":[],"category":"Smileys & Emotion","description":"heart decoration","unicode_version":"6.0"},{"emoji":"❣️","aliases":["heavy_heart_exclamation"],"tags":[],"category":"Smileys & Emotion","description":"heart exclamation","unicode_version":""},{"emoji":"💔","aliases":["broken_heart"],"tags":[],"category":"Smileys & Emotion","description":"broken heart","unicode_version":"6.0"},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"],"tags":[],"category":"Smileys & Emotion","description":"heart on fire","unicode_version":"13.1"},{"emoji":"❤️‍🩹","aliases":["mending_heart"],"tags":[],"category":"Smileys & Emotion","description":"mending heart","unicode_version":"13.1"},{"emoji":"❤️","aliases":["heart"],"tags":["love"],"category":"Smileys & Emotion","description":"red heart","unicode_version":""},{"emoji":"🧡","aliases":["orange_heart"],"tags":[],"category":"Smileys & Emotion","description":"orange heart","unicode_version":"11.0"},{"emoji":"💛","aliases":["yellow_heart"],"tags":[],"category":"Smileys & Emotion","description":"yellow heart","unicode_version":"6.0"},{"emoji":"💚","aliases":["green_heart"],"tags":[],"category":"Smileys & Emotion","description":"green heart","unicode_version":"6.0"},{"emoji":"💙","aliases":["blue_heart"],"tags":[],"category":"Smileys & Emotion","description":"blue heart","unicode_version":"6.0"},{"emoji":"💜","aliases":["purple_heart"],"tags":[],"category":"Smileys & Emotion","description":"purple heart","unicode_version":"6.0"},{"emoji":"🤎","aliases":["brown_heart"],"tags":[],"category":"Smileys & Emotion","description":"brown heart","unicode_version":"12.0"},{"emoji":"🖤","aliases":["black_heart"],"tags":[],"category":"Smileys & Emotion","description":"black heart","unicode_version":"9.0"},{"emoji":"🤍","aliases":["white_heart"],"tags":[],"category":"Smileys & Emotion","description":"white heart","unicode_version":"12.0"},{"emoji":"💯","aliases":["100"],"tags":["score","perfect"],"category":"Smileys & Emotion","description":"hundred points","unicode_version":"6.0"},{"emoji":"💢","aliases":["anger"],"tags":["angry"],"category":"Smileys & Emotion","description":"anger symbol","unicode_version":"6.0"},{"emoji":"💥","aliases":["boom","collision"],"tags":["explode"],"category":"Smileys & Emotion","description":"collision","unicode_version":"6.0"},{"emoji":"💫","aliases":["dizzy"],"tags":["star"],"category":"Smileys & Emotion","description":"dizzy","unicode_version":"6.0"},{"emoji":"💦","aliases":["sweat_drops"],"tags":["water","workout"],"category":"Smileys & Emotion","description":"sweat droplets","unicode_version":"6.0"},{"emoji":"💨","aliases":["dash"],"tags":["wind","blow","fast"],"category":"Smileys & Emotion","description":"dashing away","unicode_version":"6.0"},{"emoji":"🕳️","aliases":["hole"],"tags":[],"category":"Smileys & Emotion","description":"hole","unicode_version":"7.0"},{"emoji":"💣","aliases":["bomb"],"tags":["boom"],"category":"Smileys & Emotion","description":"bomb","unicode_version":"6.0"},{"emoji":"💬","aliases":["speech_balloon"],"tags":["comment"],"category":"Smileys & Emotion","description":"speech balloon","unicode_version":"6.0"},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"eye in speech bubble","unicode_version":"11.0"},{"emoji":"🗨️","aliases":["left_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"left speech bubble","unicode_version":"11.0"},{"emoji":"🗯️","aliases":["right_anger_bubble"],"tags":[],"category":"Smileys & Emotion","description":"right anger bubble","unicode_version":"7.0"},{"emoji":"💭","aliases":["thought_balloon"],"tags":["thinking"],"category":"Smileys & Emotion","description":"thought balloon","unicode_version":"6.0"},{"emoji":"💤","aliases":["zzz"],"tags":["sleeping"],"category":"Smileys & Emotion","description":"zzz","unicode_version":"6.0"},{"emoji":"👋","aliases":["wave"],"tags":["goodbye"],"category":"People & Body","description":"waving hand","unicode_version":"6.0"},{"emoji":"🤚","aliases":["raised_back_of_hand"],"tags":[],"category":"People & Body","description":"raised back of hand","unicode_version":"9.0"},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"],"tags":[],"category":"People & Body","description":"hand with fingers splayed","unicode_version":"7.0"},{"emoji":"✋","aliases":["hand","raised_hand"],"tags":["highfive","stop"],"category":"People & Body","description":"raised hand","unicode_version":"6.0"},{"emoji":"🖖","aliases":["vulcan_salute"],"tags":["prosper","spock"],"category":"People & Body","description":"vulcan salute","unicode_version":"7.0"},{"emoji":"👌","aliases":["ok_hand"],"tags":[],"category":"People & Body","description":"OK hand","unicode_version":"6.0"},{"emoji":"🤌","aliases":["pinched_fingers"],"tags":[],"category":"People & Body","description":"pinched fingers","unicode_version":"13.0"},{"emoji":"🤏","aliases":["pinching_hand"],"tags":[],"category":"People & Body","description":"pinching hand","unicode_version":"12.0"},{"emoji":"✌️","aliases":["v"],"tags":["victory","peace"],"category":"People & Body","description":"victory hand","unicode_version":""},{"emoji":"🤞","aliases":["crossed_fingers"],"tags":["luck","hopeful"],"category":"People & Body","description":"crossed fingers","unicode_version":"9.0"},{"emoji":"🤟","aliases":["love_you_gesture"],"tags":[],"category":"People & Body","description":"love-you gesture","unicode_version":"11.0"},{"emoji":"🤘","aliases":["metal"],"tags":[],"category":"People & Body","description":"sign of the horns","unicode_version":"8.0"},{"emoji":"🤙","aliases":["call_me_hand"],"tags":[],"category":"People & Body","description":"call me hand","unicode_version":"9.0"},{"emoji":"👈","aliases":["point_left"],"tags":[],"category":"People & Body","description":"backhand index pointing left","unicode_version":"6.0"},{"emoji":"👉","aliases":["point_right"],"tags":[],"category":"People & Body","description":"backhand index pointing right","unicode_version":"6.0"},{"emoji":"👆","aliases":["point_up_2"],"tags":[],"category":"People & Body","description":"backhand index pointing up","unicode_version":"6.0"},{"emoji":"🖕","aliases":["middle_finger","fu"],"tags":[],"category":"People & Body","description":"middle finger","unicode_version":"7.0"},{"emoji":"👇","aliases":["point_down"],"tags":[],"category":"People & Body","description":"backhand index pointing down","unicode_version":"6.0"},{"emoji":"☝️","aliases":["point_up"],"tags":[],"category":"People & Body","description":"index pointing up","unicode_version":""},{"emoji":"👍","aliases":["+1","thumbsup"],"tags":["approve","ok"],"category":"People & Body","description":"thumbs up","unicode_version":"6.0"},{"emoji":"👎","aliases":["-1","thumbsdown"],"tags":["disapprove","bury"],"category":"People & Body","description":"thumbs down","unicode_version":"6.0"},{"emoji":"✊","aliases":["fist_raised","fist"],"tags":["power"],"category":"People & Body","description":"raised fist","unicode_version":"6.0"},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"],"tags":["attack"],"category":"People & Body","description":"oncoming fist","unicode_version":"6.0"},{"emoji":"🤛","aliases":["fist_left"],"tags":[],"category":"People & Body","description":"left-facing fist","unicode_version":"9.0"},{"emoji":"🤜","aliases":["fist_right"],"tags":[],"category":"People & Body","description":"right-facing fist","unicode_version":"9.0"},{"emoji":"👏","aliases":["clap"],"tags":["praise","applause"],"category":"People & Body","description":"clapping hands","unicode_version":"6.0"},{"emoji":"🙌","aliases":["raised_hands"],"tags":["hooray"],"category":"People & Body","description":"raising hands","unicode_version":"6.0"},{"emoji":"👐","aliases":["open_hands"],"tags":[],"category":"People & Body","description":"open hands","unicode_version":"6.0"},{"emoji":"🤲","aliases":["palms_up_together"],"tags":[],"category":"People & Body","description":"palms up together","unicode_version":"11.0"},{"emoji":"🤝","aliases":["handshake"],"tags":["deal"],"category":"People & Body","description":"handshake","unicode_version":"9.0"},{"emoji":"🙏","aliases":["pray"],"tags":["please","hope","wish"],"category":"People & Body","description":"folded hands","unicode_version":"6.0"},{"emoji":"✍️","aliases":["writing_hand"],"tags":[],"category":"People & Body","description":"writing hand","unicode_version":""},{"emoji":"💅","aliases":["nail_care"],"tags":["beauty","manicure"],"category":"People & Body","description":"nail polish","unicode_version":"6.0"},{"emoji":"🤳","aliases":["selfie"],"tags":[],"category":"People & Body","description":"selfie","unicode_version":"9.0"},{"emoji":"💪","aliases":["muscle"],"tags":["flex","bicep","strong","workout"],"category":"People & Body","description":"flexed biceps","unicode_version":"6.0"},{"emoji":"🦾","aliases":["mechanical_arm"],"tags":[],"category":"People & Body","description":"mechanical arm","unicode_version":"12.0"},{"emoji":"🦿","aliases":["mechanical_leg"],"tags":[],"category":"People & Body","description":"mechanical leg","unicode_version":"12.0"},{"emoji":"🦵","aliases":["leg"],"tags":[],"category":"People & Body","description":"leg","unicode_version":"11.0"},{"emoji":"🦶","aliases":["foot"],"tags":[],"category":"People & Body","description":"foot","unicode_version":"11.0"},{"emoji":"👂","aliases":["ear"],"tags":["hear","sound","listen"],"category":"People & Body","description":"ear","unicode_version":"6.0"},{"emoji":"🦻","aliases":["ear_with_hearing_aid"],"tags":[],"category":"People & Body","description":"ear with hearing aid","unicode_version":"12.0"},{"emoji":"👃","aliases":["nose"],"tags":["smell"],"category":"People & Body","description":"nose","unicode_version":"6.0"},{"emoji":"🧠","aliases":["brain"],"tags":[],"category":"People & Body","description":"brain","unicode_version":"11.0"},{"emoji":"🫀","aliases":["anatomical_heart"],"tags":[],"category":"People & Body","description":"anatomical heart","unicode_version":"13.0"},{"emoji":"🫁","aliases":["lungs"],"tags":[],"category":"People & Body","description":"lungs","unicode_version":"13.0"},{"emoji":"🦷","aliases":["tooth"],"tags":[],"category":"People & Body","description":"tooth","unicode_version":"11.0"},{"emoji":"🦴","aliases":["bone"],"tags":[],"category":"People & Body","description":"bone","unicode_version":"11.0"},{"emoji":"👀","aliases":["eyes"],"tags":["look","see","watch"],"category":"People & Body","description":"eyes","unicode_version":"6.0"},{"emoji":"👁️","aliases":["eye"],"tags":[],"category":"People & Body","description":"eye","unicode_version":"7.0"},{"emoji":"👅","aliases":["tongue"],"tags":["taste"],"category":"People & Body","description":"tongue","unicode_version":"6.0"},{"emoji":"👄","aliases":["lips"],"tags":["kiss"],"category":"People & Body","description":"mouth","unicode_version":"6.0"},{"emoji":"👶","aliases":["baby"],"tags":["child","newborn"],"category":"People & Body","description":"baby","unicode_version":"6.0"},{"emoji":"🧒","aliases":["child"],"tags":[],"category":"People & Body","description":"child","unicode_version":"11.0"},{"emoji":"👦","aliases":["boy"],"tags":["child"],"category":"People & Body","description":"boy","unicode_version":"6.0"},{"emoji":"👧","aliases":["girl"],"tags":["child"],"category":"People & Body","description":"girl","unicode_version":"6.0"},{"emoji":"🧑","aliases":["adult"],"tags":[],"category":"People & Body","description":"person","unicode_version":"11.0"},{"emoji":"👱","aliases":["blond_haired_person"],"tags":[],"category":"People & Body","description":"person: blond hair","unicode_version":"6.0"},{"emoji":"👨","aliases":["man"],"tags":["mustache","father","dad"],"category":"People & Body","description":"man","unicode_version":"6.0"},{"emoji":"🧔","aliases":["bearded_person"],"tags":[],"category":"People & Body","description":"person: beard","unicode_version":"11.0"},{"emoji":"🧔‍♂️","aliases":["man_beard"],"tags":[],"category":"People & Body","description":"man: beard","unicode_version":"13.1"},{"emoji":"🧔‍♀️","aliases":["woman_beard"],"tags":[],"category":"People & Body","description":"woman: beard","unicode_version":"13.1"},{"emoji":"👨‍🦰","aliases":["red_haired_man"],"tags":[],"category":"People & Body","description":"man: red hair","unicode_version":"11.0"},{"emoji":"👨‍🦱","aliases":["curly_haired_man"],"tags":[],"category":"People & Body","description":"man: curly hair","unicode_version":"11.0"},{"emoji":"👨‍🦳","aliases":["white_haired_man"],"tags":[],"category":"People & Body","description":"man: white hair","unicode_version":"11.0"},{"emoji":"👨‍🦲","aliases":["bald_man"],"tags":[],"category":"People & Body","description":"man: bald","unicode_version":"11.0"},{"emoji":"👩","aliases":["woman"],"tags":["girls"],"category":"People & Body","description":"woman","unicode_version":"6.0"},{"emoji":"👩‍🦰","aliases":["red_haired_woman"],"tags":[],"category":"People & Body","description":"woman: red hair","unicode_version":"11.0"},{"emoji":"🧑‍🦰","aliases":["person_red_hair"],"tags":[],"category":"People & Body","description":"person: red hair","unicode_version":"12.1"},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"],"tags":[],"category":"People & Body","description":"woman: curly hair","unicode_version":"11.0"},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"],"tags":[],"category":"People & Body","description":"person: curly hair","unicode_version":"12.1"},{"emoji":"👩‍🦳","aliases":["white_haired_woman"],"tags":[],"category":"People & Body","description":"woman: white hair","unicode_version":"11.0"},{"emoji":"🧑‍🦳","aliases":["person_white_hair"],"tags":[],"category":"People & Body","description":"person: white hair","unicode_version":"12.1"},{"emoji":"👩‍🦲","aliases":["bald_woman"],"tags":[],"category":"People & Body","description":"woman: bald","unicode_version":"11.0"},{"emoji":"🧑‍🦲","aliases":["person_bald"],"tags":[],"category":"People & Body","description":"person: bald","unicode_version":"12.1"},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"],"tags":[],"category":"People & Body","description":"woman: blond hair","unicode_version":"6.0"},{"emoji":"👱‍♂️","aliases":["blond_haired_man"],"tags":[],"category":"People & Body","description":"man: blond hair","unicode_version":"11.0"},{"emoji":"🧓","aliases":["older_adult"],"tags":[],"category":"People & Body","description":"older person","unicode_version":"11.0"},{"emoji":"👴","aliases":["older_man"],"tags":[],"category":"People & Body","description":"old man","unicode_version":"6.0"},{"emoji":"👵","aliases":["older_woman"],"tags":[],"category":"People & Body","description":"old woman","unicode_version":"6.0"},{"emoji":"🙍","aliases":["frowning_person"],"tags":[],"category":"People & Body","description":"person frowning","unicode_version":"6.0"},{"emoji":"🙍‍♂️","aliases":["frowning_man"],"tags":[],"category":"People & Body","description":"man frowning","unicode_version":"6.0"},{"emoji":"🙍‍♀️","aliases":["frowning_woman"],"tags":[],"category":"People & Body","description":"woman frowning","unicode_version":"11.0"},{"emoji":"🙎","aliases":["pouting_face"],"tags":[],"category":"People & Body","description":"person pouting","unicode_version":"6.0"},{"emoji":"🙎‍♂️","aliases":["pouting_man"],"tags":[],"category":"People & Body","description":"man pouting","unicode_version":"6.0"},{"emoji":"🙎‍♀️","aliases":["pouting_woman"],"tags":[],"category":"People & Body","description":"woman pouting","unicode_version":"11.0"},{"emoji":"🙅","aliases":["no_good"],"tags":["stop","halt","denied"],"category":"People & Body","description":"person gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"],"tags":["stop","halt","denied"],"category":"People & Body","description":"man gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"],"tags":["stop","halt","denied"],"category":"People & Body","description":"woman gesturing NO","unicode_version":"11.0"},{"emoji":"🙆","aliases":["ok_person"],"tags":[],"category":"People & Body","description":"person gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♂️","aliases":["ok_man"],"tags":[],"category":"People & Body","description":"man gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♀️","aliases":["ok_woman"],"tags":[],"category":"People & Body","description":"woman gesturing OK","unicode_version":"11.0"},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"],"tags":[],"category":"People & Body","description":"person tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"],"tags":["information"],"category":"People & Body","description":"man tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"],"tags":["information"],"category":"People & Body","description":"woman tipping hand","unicode_version":"11.0"},{"emoji":"🙋","aliases":["raising_hand"],"tags":[],"category":"People & Body","description":"person raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"],"tags":[],"category":"People & Body","description":"man raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"],"tags":[],"category":"People & Body","description":"woman raising hand","unicode_version":"11.0"},{"emoji":"🧏","aliases":["deaf_person"],"tags":[],"category":"People & Body","description":"deaf person","unicode_version":"12.0"},{"emoji":"🧏‍♂️","aliases":["deaf_man"],"tags":[],"category":"People & Body","description":"deaf man","unicode_version":"12.0"},{"emoji":"🧏‍♀️","aliases":["deaf_woman"],"tags":[],"category":"People & Body","description":"deaf woman","unicode_version":"12.0"},{"emoji":"🙇","aliases":["bow"],"tags":["respect","thanks"],"category":"People & Body","description":"person bowing","unicode_version":"6.0"},{"emoji":"🙇‍♂️","aliases":["bowing_man"],"tags":["respect","thanks"],"category":"People & Body","description":"man bowing","unicode_version":"11.0"},{"emoji":"🙇‍♀️","aliases":["bowing_woman"],"tags":["respect","thanks"],"category":"People & Body","description":"woman bowing","unicode_version":"6.0"},{"emoji":"🤦","aliases":["facepalm"],"tags":[],"category":"People & Body","description":"person facepalming","unicode_version":"11.0"},{"emoji":"🤦‍♂️","aliases":["man_facepalming"],"tags":[],"category":"People & Body","description":"man facepalming","unicode_version":"9.0"},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"],"tags":[],"category":"People & Body","description":"woman facepalming","unicode_version":"9.0"},{"emoji":"🤷","aliases":["shrug"],"tags":[],"category":"People & Body","description":"person shrugging","unicode_version":"11.0"},{"emoji":"🤷‍♂️","aliases":["man_shrugging"],"tags":[],"category":"People & Body","description":"man shrugging","unicode_version":"9.0"},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"],"tags":[],"category":"People & Body","description":"woman shrugging","unicode_version":"9.0"},{"emoji":"🧑‍⚕️","aliases":["health_worker"],"tags":[],"category":"People & Body","description":"health worker","unicode_version":"12.1"},{"emoji":"👨‍⚕️","aliases":["man_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"man health worker","unicode_version":""},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"woman health worker","unicode_version":""},{"emoji":"🧑‍🎓","aliases":["student"],"tags":[],"category":"People & Body","description":"student","unicode_version":"12.1"},{"emoji":"👨‍🎓","aliases":["man_student"],"tags":["graduation"],"category":"People & Body","description":"man student","unicode_version":""},{"emoji":"👩‍🎓","aliases":["woman_student"],"tags":["graduation"],"category":"People & Body","description":"woman student","unicode_version":""},{"emoji":"🧑‍🏫","aliases":["teacher"],"tags":[],"category":"People & Body","description":"teacher","unicode_version":"12.1"},{"emoji":"👨‍🏫","aliases":["man_teacher"],"tags":["school","professor"],"category":"People & Body","description":"man teacher","unicode_version":""},{"emoji":"👩‍🏫","aliases":["woman_teacher"],"tags":["school","professor"],"category":"People & Body","description":"woman teacher","unicode_version":""},{"emoji":"🧑‍⚖️","aliases":["judge"],"tags":[],"category":"People & Body","description":"judge","unicode_version":"12.1"},{"emoji":"👨‍⚖️","aliases":["man_judge"],"tags":["justice"],"category":"People & Body","description":"man judge","unicode_version":""},{"emoji":"👩‍⚖️","aliases":["woman_judge"],"tags":["justice"],"category":"People & Body","description":"woman judge","unicode_version":""},{"emoji":"🧑‍🌾","aliases":["farmer"],"tags":[],"category":"People & Body","description":"farmer","unicode_version":"12.1"},{"emoji":"👨‍🌾","aliases":["man_farmer"],"tags":[],"category":"People & Body","description":"man farmer","unicode_version":""},{"emoji":"👩‍🌾","aliases":["woman_farmer"],"tags":[],"category":"People & Body","description":"woman farmer","unicode_version":""},{"emoji":"🧑‍🍳","aliases":["cook"],"tags":[],"category":"People & Body","description":"cook","unicode_version":"12.1"},{"emoji":"👨‍🍳","aliases":["man_cook"],"tags":["chef"],"category":"People & Body","description":"man cook","unicode_version":""},{"emoji":"👩‍🍳","aliases":["woman_cook"],"tags":["chef"],"category":"People & Body","description":"woman cook","unicode_version":""},{"emoji":"🧑‍🔧","aliases":["mechanic"],"tags":[],"category":"People & Body","description":"mechanic","unicode_version":"12.1"},{"emoji":"👨‍🔧","aliases":["man_mechanic"],"tags":[],"category":"People & Body","description":"man mechanic","unicode_version":""},{"emoji":"👩‍🔧","aliases":["woman_mechanic"],"tags":[],"category":"People & Body","description":"woman mechanic","unicode_version":""},{"emoji":"🧑‍🏭","aliases":["factory_worker"],"tags":[],"category":"People & Body","description":"factory worker","unicode_version":"12.1"},{"emoji":"👨‍🏭","aliases":["man_factory_worker"],"tags":[],"category":"People & Body","description":"man factory worker","unicode_version":""},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"],"tags":[],"category":"People & Body","description":"woman factory worker","unicode_version":""},{"emoji":"🧑‍💼","aliases":["office_worker"],"tags":[],"category":"People & Body","description":"office worker","unicode_version":"12.1"},{"emoji":"👨‍💼","aliases":["man_office_worker"],"tags":["business"],"category":"People & Body","description":"man office worker","unicode_version":""},{"emoji":"👩‍💼","aliases":["woman_office_worker"],"tags":["business"],"category":"People & Body","description":"woman office worker","unicode_version":""},{"emoji":"🧑‍🔬","aliases":["scientist"],"tags":[],"category":"People & Body","description":"scientist","unicode_version":"12.1"},{"emoji":"👨‍🔬","aliases":["man_scientist"],"tags":["research"],"category":"People & Body","description":"man scientist","unicode_version":""},{"emoji":"👩‍🔬","aliases":["woman_scientist"],"tags":["research"],"category":"People & Body","description":"woman scientist","unicode_version":""},{"emoji":"🧑‍💻","aliases":["technologist"],"tags":[],"category":"People & Body","description":"technologist","unicode_version":"12.1"},{"emoji":"👨‍💻","aliases":["man_technologist"],"tags":["coder"],"category":"People & Body","description":"man technologist","unicode_version":""},{"emoji":"👩‍💻","aliases":["woman_technologist"],"tags":["coder"],"category":"People & Body","description":"woman technologist","unicode_version":""},{"emoji":"🧑‍🎤","aliases":["singer"],"tags":[],"category":"People & Body","description":"singer","unicode_version":"12.1"},{"emoji":"👨‍🎤","aliases":["man_singer"],"tags":["rockstar"],"category":"People & Body","description":"man singer","unicode_version":""},{"emoji":"👩‍🎤","aliases":["woman_singer"],"tags":["rockstar"],"category":"People & Body","description":"woman singer","unicode_version":""},{"emoji":"🧑‍🎨","aliases":["artist"],"tags":[],"category":"People & Body","description":"artist","unicode_version":"12.1"},{"emoji":"👨‍🎨","aliases":["man_artist"],"tags":["painter"],"category":"People & Body","description":"man artist","unicode_version":""},{"emoji":"👩‍🎨","aliases":["woman_artist"],"tags":["painter"],"category":"People & Body","description":"woman artist","unicode_version":""},{"emoji":"🧑‍✈️","aliases":["pilot"],"tags":[],"category":"People & Body","description":"pilot","unicode_version":"12.1"},{"emoji":"👨‍✈️","aliases":["man_pilot"],"tags":[],"category":"People & Body","description":"man pilot","unicode_version":""},{"emoji":"👩‍✈️","aliases":["woman_pilot"],"tags":[],"category":"People & Body","description":"woman pilot","unicode_version":""},{"emoji":"🧑‍🚀","aliases":["astronaut"],"tags":[],"category":"People & Body","description":"astronaut","unicode_version":"12.1"},{"emoji":"👨‍🚀","aliases":["man_astronaut"],"tags":["space"],"category":"People & Body","description":"man astronaut","unicode_version":""},{"emoji":"👩‍🚀","aliases":["woman_astronaut"],"tags":["space"],"category":"People & Body","description":"woman astronaut","unicode_version":""},{"emoji":"🧑‍🚒","aliases":["firefighter"],"tags":[],"category":"People & Body","description":"firefighter","unicode_version":"12.1"},{"emoji":"👨‍🚒","aliases":["man_firefighter"],"tags":[],"category":"People & Body","description":"man firefighter","unicode_version":""},{"emoji":"👩‍🚒","aliases":["woman_firefighter"],"tags":[],"category":"People & Body","description":"woman firefighter","unicode_version":""},{"emoji":"👮","aliases":["police_officer","cop"],"tags":["law"],"category":"People & Body","description":"police officer","unicode_version":"6.0"},{"emoji":"👮‍♂️","aliases":["policeman"],"tags":["law","cop"],"category":"People & Body","description":"man police officer","unicode_version":"11.0"},{"emoji":"👮‍♀️","aliases":["policewoman"],"tags":["law","cop"],"category":"People & Body","description":"woman police officer","unicode_version":"6.0"},{"emoji":"🕵️","aliases":["detective"],"tags":["sleuth"],"category":"People & Body","description":"detective","unicode_version":"7.0"},{"emoji":"🕵️‍♂️","aliases":["male_detective"],"tags":["sleuth"],"category":"People & Body","description":"man detective","unicode_version":"11.0"},{"emoji":"🕵️‍♀️","aliases":["female_detective"],"tags":["sleuth"],"category":"People & Body","description":"woman detective","unicode_version":"6.0"},{"emoji":"💂","aliases":["guard"],"tags":[],"category":"People & Body","description":"guard","unicode_version":"6.0"},{"emoji":"💂‍♂️","aliases":["guardsman"],"tags":[],"category":"People & Body","description":"man guard","unicode_version":"11.0"},{"emoji":"💂‍♀️","aliases":["guardswoman"],"tags":[],"category":"People & Body","description":"woman guard","unicode_version":"6.0"},{"emoji":"🥷","aliases":["ninja"],"tags":[],"category":"People & Body","description":"ninja","unicode_version":"13.0"},{"emoji":"👷","aliases":["construction_worker"],"tags":["helmet"],"category":"People & Body","description":"construction worker","unicode_version":"6.0"},{"emoji":"👷‍♂️","aliases":["construction_worker_man"],"tags":["helmet"],"category":"People & Body","description":"man construction worker","unicode_version":"11.0"},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"],"tags":["helmet"],"category":"People & Body","description":"woman construction worker","unicode_version":"6.0"},{"emoji":"🤴","aliases":["prince"],"tags":["crown","royal"],"category":"People & Body","description":"prince","unicode_version":"9.0"},{"emoji":"👸","aliases":["princess"],"tags":["crown","royal"],"category":"People & Body","description":"princess","unicode_version":"6.0"},{"emoji":"👳","aliases":["person_with_turban"],"tags":[],"category":"People & Body","description":"person wearing turban","unicode_version":"6.0"},{"emoji":"👳‍♂️","aliases":["man_with_turban"],"tags":[],"category":"People & Body","description":"man wearing turban","unicode_version":"11.0"},{"emoji":"👳‍♀️","aliases":["woman_with_turban"],"tags":[],"category":"People & Body","description":"woman wearing turban","unicode_version":"6.0"},{"emoji":"👲","aliases":["man_with_gua_pi_mao"],"tags":[],"category":"People & Body","description":"person with skullcap","unicode_version":"6.0"},{"emoji":"🧕","aliases":["woman_with_headscarf"],"tags":["hijab"],"category":"People & Body","description":"woman with headscarf","unicode_version":"11.0"},{"emoji":"🤵","aliases":["person_in_tuxedo"],"tags":["groom","marriage","wedding"],"category":"People & Body","description":"person in tuxedo","unicode_version":"9.0"},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"],"tags":[],"category":"People & Body","description":"man in tuxedo","unicode_version":"13.0"},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"],"tags":[],"category":"People & Body","description":"woman in tuxedo","unicode_version":"13.0"},{"emoji":"👰","aliases":["person_with_veil"],"tags":["marriage","wedding"],"category":"People & Body","description":"person with veil","unicode_version":"6.0"},{"emoji":"👰‍♂️","aliases":["man_with_veil"],"tags":[],"category":"People & Body","description":"man with veil","unicode_version":"13.0"},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"],"tags":[],"category":"People & Body","description":"woman with veil","unicode_version":"13.0"},{"emoji":"🤰","aliases":["pregnant_woman"],"tags":[],"category":"People & Body","description":"pregnant woman","unicode_version":"9.0"},{"emoji":"🤱","aliases":["breast_feeding"],"tags":["nursing"],"category":"People & Body","description":"breast-feeding","unicode_version":"11.0"},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"],"tags":[],"category":"People & Body","description":"woman feeding baby","unicode_version":"13.0"},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"],"tags":[],"category":"People & Body","description":"man feeding baby","unicode_version":"13.0"},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"],"tags":[],"category":"People & Body","description":"person feeding baby","unicode_version":"13.0"},{"emoji":"👼","aliases":["angel"],"tags":[],"category":"People & Body","description":"baby angel","unicode_version":"6.0"},{"emoji":"🎅","aliases":["santa"],"tags":["christmas"],"category":"People & Body","description":"Santa Claus","unicode_version":"6.0"},{"emoji":"🤶","aliases":["mrs_claus"],"tags":["santa"],"category":"People & Body","description":"Mrs. Claus","unicode_version":"9.0"},{"emoji":"🧑‍🎄","aliases":["mx_claus"],"tags":[],"category":"People & Body","description":"mx claus","unicode_version":"13.0"},{"emoji":"🦸","aliases":["superhero"],"tags":[],"category":"People & Body","description":"superhero","unicode_version":"11.0"},{"emoji":"🦸‍♂️","aliases":["superhero_man"],"tags":[],"category":"People & Body","description":"man superhero","unicode_version":"11.0"},{"emoji":"🦸‍♀️","aliases":["superhero_woman"],"tags":[],"category":"People & Body","description":"woman superhero","unicode_version":"11.0"},{"emoji":"🦹","aliases":["supervillain"],"tags":[],"category":"People & Body","description":"supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♂️","aliases":["supervillain_man"],"tags":[],"category":"People & Body","description":"man supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"],"tags":[],"category":"People & Body","description":"woman supervillain","unicode_version":"11.0"},{"emoji":"🧙","aliases":["mage"],"tags":["wizard"],"category":"People & Body","description":"mage","unicode_version":"11.0"},{"emoji":"🧙‍♂️","aliases":["mage_man"],"tags":["wizard"],"category":"People & Body","description":"man mage","unicode_version":"11.0"},{"emoji":"🧙‍♀️","aliases":["mage_woman"],"tags":["wizard"],"category":"People & Body","description":"woman mage","unicode_version":"11.0"},{"emoji":"🧚","aliases":["fairy"],"tags":[],"category":"People & Body","description":"fairy","unicode_version":"11.0"},{"emoji":"🧚‍♂️","aliases":["fairy_man"],"tags":[],"category":"People & Body","description":"man fairy","unicode_version":"11.0"},{"emoji":"🧚‍♀️","aliases":["fairy_woman"],"tags":[],"category":"People & Body","description":"woman fairy","unicode_version":"11.0"},{"emoji":"🧛","aliases":["vampire"],"tags":[],"category":"People & Body","description":"vampire","unicode_version":"11.0"},{"emoji":"🧛‍♂️","aliases":["vampire_man"],"tags":[],"category":"People & Body","description":"man vampire","unicode_version":"11.0"},{"emoji":"🧛‍♀️","aliases":["vampire_woman"],"tags":[],"category":"People & Body","description":"woman vampire","unicode_version":"11.0"},{"emoji":"🧜","aliases":["merperson"],"tags":[],"category":"People & Body","description":"merperson","unicode_version":"11.0"},{"emoji":"🧜‍♂️","aliases":["merman"],"tags":[],"category":"People & Body","description":"merman","unicode_version":"11.0"},{"emoji":"🧜‍♀️","aliases":["mermaid"],"tags":[],"category":"People & Body","description":"mermaid","unicode_version":"11.0"},{"emoji":"🧝","aliases":["elf"],"tags":[],"category":"People & Body","description":"elf","unicode_version":"11.0"},{"emoji":"🧝‍♂️","aliases":["elf_man"],"tags":[],"category":"People & Body","description":"man elf","unicode_version":"11.0"},{"emoji":"🧝‍♀️","aliases":["elf_woman"],"tags":[],"category":"People & Body","description":"woman elf","unicode_version":"11.0"},{"emoji":"🧞","aliases":["genie"],"tags":[],"category":"People & Body","description":"genie","unicode_version":"11.0"},{"emoji":"🧞‍♂️","aliases":["genie_man"],"tags":[],"category":"People & Body","description":"man genie","unicode_version":"11.0"},{"emoji":"🧞‍♀️","aliases":["genie_woman"],"tags":[],"category":"People & Body","description":"woman genie","unicode_version":"11.0"},{"emoji":"🧟","aliases":["zombie"],"tags":[],"category":"People & Body","description":"zombie","unicode_version":"11.0"},{"emoji":"🧟‍♂️","aliases":["zombie_man"],"tags":[],"category":"People & Body","description":"man zombie","unicode_version":"11.0"},{"emoji":"🧟‍♀️","aliases":["zombie_woman"],"tags":[],"category":"People & Body","description":"woman zombie","unicode_version":"11.0"},{"emoji":"💆","aliases":["massage"],"tags":["spa"],"category":"People & Body","description":"person getting massage","unicode_version":"6.0"},{"emoji":"💆‍♂️","aliases":["massage_man"],"tags":["spa"],"category":"People & Body","description":"man getting massage","unicode_version":"6.0"},{"emoji":"💆‍♀️","aliases":["massage_woman"],"tags":["spa"],"category":"People & Body","description":"woman getting massage","unicode_version":"11.0"},{"emoji":"💇","aliases":["haircut"],"tags":["beauty"],"category":"People & Body","description":"person getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♂️","aliases":["haircut_man"],"tags":[],"category":"People & Body","description":"man getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♀️","aliases":["haircut_woman"],"tags":[],"category":"People & Body","description":"woman getting haircut","unicode_version":"11.0"},{"emoji":"🚶","aliases":["walking"],"tags":[],"category":"People & Body","description":"person walking","unicode_version":"6.0"},{"emoji":"🚶‍♂️","aliases":["walking_man"],"tags":[],"category":"People & Body","description":"man walking","unicode_version":"11.0"},{"emoji":"🚶‍♀️","aliases":["walking_woman"],"tags":[],"category":"People & Body","description":"woman walking","unicode_version":"6.0"},{"emoji":"🧍","aliases":["standing_person"],"tags":[],"category":"People & Body","description":"person standing","unicode_version":"12.0"},{"emoji":"🧍‍♂️","aliases":["standing_man"],"tags":[],"category":"People & Body","description":"man standing","unicode_version":"12.0"},{"emoji":"🧍‍♀️","aliases":["standing_woman"],"tags":[],"category":"People & Body","description":"woman standing","unicode_version":"12.0"},{"emoji":"🧎","aliases":["kneeling_person"],"tags":[],"category":"People & Body","description":"person kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♂️","aliases":["kneeling_man"],"tags":[],"category":"People & Body","description":"man kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"],"tags":[],"category":"People & Body","description":"woman kneeling","unicode_version":"12.0"},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"],"tags":[],"category":"People & Body","description":"person with white cane","unicode_version":"12.1"},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"],"tags":[],"category":"People & Body","description":"man with white cane","unicode_version":"12.0"},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"],"tags":[],"category":"People & Body","description":"woman with white cane","unicode_version":"12.0"},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"person in motorized wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"man in motorized wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"woman in motorized wheelchair","unicode_version":"12.0"},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"person in manual wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"man in manual wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"woman in manual wheelchair","unicode_version":"12.0"},{"emoji":"🏃","aliases":["runner","running"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"person running","unicode_version":"6.0"},{"emoji":"🏃‍♂️","aliases":["running_man"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"man running","unicode_version":"11.0"},{"emoji":"🏃‍♀️","aliases":["running_woman"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"woman running","unicode_version":"6.0"},{"emoji":"💃","aliases":["woman_dancing","dancer"],"tags":["dress"],"category":"People & Body","description":"woman dancing","unicode_version":"6.0"},{"emoji":"🕺","aliases":["man_dancing"],"tags":["dancer"],"category":"People & Body","description":"man dancing","unicode_version":"9.0"},{"emoji":"🕴️","aliases":["business_suit_levitating"],"tags":[],"category":"People & Body","description":"person in suit levitating","unicode_version":"7.0"},{"emoji":"👯","aliases":["dancers"],"tags":["bunny"],"category":"People & Body","description":"people with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♂️","aliases":["dancing_men"],"tags":["bunny"],"category":"People & Body","description":"men with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♀️","aliases":["dancing_women"],"tags":["bunny"],"category":"People & Body","description":"women with bunny ears","unicode_version":"11.0"},{"emoji":"🧖","aliases":["sauna_person"],"tags":["steamy"],"category":"People & Body","description":"person in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♂️","aliases":["sauna_man"],"tags":["steamy"],"category":"People & Body","description":"man in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♀️","aliases":["sauna_woman"],"tags":["steamy"],"category":"People & Body","description":"woman in steamy room","unicode_version":"11.0"},{"emoji":"🧗","aliases":["climbing"],"tags":["bouldering"],"category":"People & Body","description":"person climbing","unicode_version":"11.0"},{"emoji":"🧗‍♂️","aliases":["climbing_man"],"tags":["bouldering"],"category":"People & Body","description":"man climbing","unicode_version":"11.0"},{"emoji":"🧗‍♀️","aliases":["climbing_woman"],"tags":["bouldering"],"category":"People & Body","description":"woman climbing","unicode_version":"11.0"},{"emoji":"🤺","aliases":["person_fencing"],"tags":[],"category":"People & Body","description":"person fencing","unicode_version":"9.0"},{"emoji":"🏇","aliases":["horse_racing"],"tags":[],"category":"People & Body","description":"horse racing","unicode_version":"6.0"},{"emoji":"⛷️","aliases":["skier"],"tags":[],"category":"People & Body","description":"skier","unicode_version":"5.2"},{"emoji":"🏂","aliases":["snowboarder"],"tags":[],"category":"People & Body","description":"snowboarder","unicode_version":"6.0"},{"emoji":"🏌️","aliases":["golfing"],"tags":[],"category":"People & Body","description":"person golfing","unicode_version":"7.0"},{"emoji":"🏌️‍♂️","aliases":["golfing_man"],"tags":[],"category":"People & Body","description":"man golfing","unicode_version":"11.0"},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"],"tags":[],"category":"People & Body","description":"woman golfing","unicode_version":""},{"emoji":"🏄","aliases":["surfer"],"tags":[],"category":"People & Body","description":"person surfing","unicode_version":"6.0"},{"emoji":"🏄‍♂️","aliases":["surfing_man"],"tags":[],"category":"People & Body","description":"man surfing","unicode_version":"11.0"},{"emoji":"🏄‍♀️","aliases":["surfing_woman"],"tags":[],"category":"People & Body","description":"woman surfing","unicode_version":"7.0"},{"emoji":"🚣","aliases":["rowboat"],"tags":[],"category":"People & Body","description":"person rowing boat","unicode_version":"6.0"},{"emoji":"🚣‍♂️","aliases":["rowing_man"],"tags":[],"category":"People & Body","description":"man rowing boat","unicode_version":"11.0"},{"emoji":"🚣‍♀️","aliases":["rowing_woman"],"tags":[],"category":"People & Body","description":"woman rowing boat","unicode_version":"6.0"},{"emoji":"🏊","aliases":["swimmer"],"tags":[],"category":"People & Body","description":"person swimming","unicode_version":"6.0"},{"emoji":"🏊‍♂️","aliases":["swimming_man"],"tags":[],"category":"People & Body","description":"man swimming","unicode_version":"11.0"},{"emoji":"🏊‍♀️","aliases":["swimming_woman"],"tags":[],"category":"People & Body","description":"woman swimming","unicode_version":"6.0"},{"emoji":"⛹️","aliases":["bouncing_ball_person"],"tags":["basketball"],"category":"People & Body","description":"person bouncing ball","unicode_version":"5.2"},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"],"tags":[],"category":"People & Body","description":"man bouncing ball","unicode_version":"11.0"},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"],"tags":[],"category":"People & Body","description":"woman bouncing ball","unicode_version":"7.0"},{"emoji":"🏋️","aliases":["weight_lifting"],"tags":["gym","workout"],"category":"People & Body","description":"person lifting weights","unicode_version":"7.0"},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"],"tags":["gym","workout"],"category":"People & Body","description":"man lifting weights","unicode_version":"11.0"},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"],"tags":["gym","workout"],"category":"People & Body","description":"woman lifting weights","unicode_version":"6.0"},{"emoji":"🚴","aliases":["bicyclist"],"tags":[],"category":"People & Body","description":"person biking","unicode_version":"6.0"},{"emoji":"🚴‍♂️","aliases":["biking_man"],"tags":[],"category":"People & Body","description":"man biking","unicode_version":"11.0"},{"emoji":"🚴‍♀️","aliases":["biking_woman"],"tags":[],"category":"People & Body","description":"woman biking","unicode_version":"6.0"},{"emoji":"🚵","aliases":["mountain_bicyclist"],"tags":[],"category":"People & Body","description":"person mountain biking","unicode_version":"6.0"},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"],"tags":[],"category":"People & Body","description":"man mountain biking","unicode_version":"11.0"},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"],"tags":[],"category":"People & Body","description":"woman mountain biking","unicode_version":"6.0"},{"emoji":"🤸","aliases":["cartwheeling"],"tags":[],"category":"People & Body","description":"person cartwheeling","unicode_version":"11.0"},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"],"tags":[],"category":"People & Body","description":"man cartwheeling","unicode_version":""},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"],"tags":[],"category":"People & Body","description":"woman cartwheeling","unicode_version":""},{"emoji":"🤼","aliases":["wrestling"],"tags":[],"category":"People & Body","description":"people wrestling","unicode_version":"11.0"},{"emoji":"🤼‍♂️","aliases":["men_wrestling"],"tags":[],"category":"People & Body","description":"men wrestling","unicode_version":"9.0"},{"emoji":"🤼‍♀️","aliases":["women_wrestling"],"tags":[],"category":"People & Body","description":"women wrestling","unicode_version":"9.0"},{"emoji":"🤽","aliases":["water_polo"],"tags":[],"category":"People & Body","description":"person playing water polo","unicode_version":"11.0"},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"],"tags":[],"category":"People & Body","description":"man playing water polo","unicode_version":"9.0"},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"],"tags":[],"category":"People & Body","description":"woman playing water polo","unicode_version":"9.0"},{"emoji":"🤾","aliases":["handball_person"],"tags":[],"category":"People & Body","description":"person playing handball","unicode_version":"11.0"},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"],"tags":[],"category":"People & Body","description":"man playing handball","unicode_version":"9.0"},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"],"tags":[],"category":"People & Body","description":"woman playing handball","unicode_version":"9.0"},{"emoji":"🤹","aliases":["juggling_person"],"tags":[],"category":"People & Body","description":"person juggling","unicode_version":"11.0"},{"emoji":"🤹‍♂️","aliases":["man_juggling"],"tags":[],"category":"People & Body","description":"man juggling","unicode_version":"9.0"},{"emoji":"🤹‍♀️","aliases":["woman_juggling"],"tags":[],"category":"People & Body","description":"woman juggling","unicode_version":"9.0"},{"emoji":"🧘","aliases":["lotus_position"],"tags":["meditation"],"category":"People & Body","description":"person in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"],"tags":["meditation"],"category":"People & Body","description":"man in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"],"tags":["meditation"],"category":"People & Body","description":"woman in lotus position","unicode_version":"11.0"},{"emoji":"🛀","aliases":["bath"],"tags":["shower"],"category":"People & Body","description":"person taking bath","unicode_version":"6.0"},{"emoji":"🛌","aliases":["sleeping_bed"],"tags":[],"category":"People & Body","description":"person in bed","unicode_version":"7.0"},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"people holding hands","unicode_version":"12.0"},{"emoji":"👭","aliases":["two_women_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"women holding hands","unicode_version":"6.0"},{"emoji":"👫","aliases":["couple"],"tags":["date"],"category":"People & Body","description":"woman and man holding hands","unicode_version":"6.0"},{"emoji":"👬","aliases":["two_men_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"men holding hands","unicode_version":"6.0"},{"emoji":"💏","aliases":["couplekiss"],"tags":[],"category":"People & Body","description":"kiss","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"],"tags":[],"category":"People & Body","description":"kiss: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, woman","unicode_version":"6.0"},{"emoji":"💑","aliases":["couple_with_heart"],"tags":[],"category":"People & Body","description":"couple with heart","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"],"tags":[],"category":"People & Body","description":"couple with heart: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"],"tags":[],"category":"People & Body","description":"couple with heart: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"],"tags":[],"category":"People & Body","description":"couple with heart: woman, woman","unicode_version":"6.0"},{"emoji":"👪","aliases":["family"],"tags":["home","parents","child"],"category":"People & Body","description":"family","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy","unicode_version":"11.0"},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👦","aliases":["family_man_boy"],"tags":[],"category":"People & Body","description":"family: man, boy","unicode_version":"6.0"},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👧","aliases":["family_man_girl"],"tags":[],"category":"People & Body","description":"family: man, girl","unicode_version":"6.0"},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👦","aliases":["family_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👧","aliases":["family_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl, girl","unicode_version":"6.0"},{"emoji":"🗣️","aliases":["speaking_head"],"tags":[],"category":"People & Body","description":"speaking head","unicode_version":"7.0"},{"emoji":"👤","aliases":["bust_in_silhouette"],"tags":["user"],"category":"People & Body","description":"bust in silhouette","unicode_version":"6.0"},{"emoji":"👥","aliases":["busts_in_silhouette"],"tags":["users","group","team"],"category":"People & Body","description":"busts in silhouette","unicode_version":"6.0"},{"emoji":"🫂","aliases":["people_hugging"],"tags":[],"category":"People & Body","description":"people hugging","unicode_version":"13.0"},{"emoji":"👣","aliases":["footprints"],"tags":["feet","tracks"],"category":"People & Body","description":"footprints","unicode_version":"6.0"},{"emoji":"🐵","aliases":["monkey_face"],"tags":[],"category":"Animals & Nature","description":"monkey face","unicode_version":"6.0"},{"emoji":"🐒","aliases":["monkey"],"tags":[],"category":"Animals & Nature","description":"monkey","unicode_version":"6.0"},{"emoji":"🦍","aliases":["gorilla"],"tags":[],"category":"Animals & Nature","description":"gorilla","unicode_version":"9.0"},{"emoji":"🦧","aliases":["orangutan"],"tags":[],"category":"Animals & Nature","description":"orangutan","unicode_version":"12.0"},{"emoji":"🐶","aliases":["dog"],"tags":["pet"],"category":"Animals & Nature","description":"dog face","unicode_version":"6.0"},{"emoji":"🐕","aliases":["dog2"],"tags":[],"category":"Animals & Nature","description":"dog","unicode_version":"6.0"},{"emoji":"🦮","aliases":["guide_dog"],"tags":[],"category":"Animals & Nature","description":"guide dog","unicode_version":"12.0"},{"emoji":"🐕‍🦺","aliases":["service_dog"],"tags":[],"category":"Animals & Nature","description":"service dog","unicode_version":"12.0"},{"emoji":"🐩","aliases":["poodle"],"tags":["dog"],"category":"Animals & Nature","description":"poodle","unicode_version":"6.0"},{"emoji":"🐺","aliases":["wolf"],"tags":[],"category":"Animals & Nature","description":"wolf","unicode_version":"6.0"},{"emoji":"🦊","aliases":["fox_face"],"tags":[],"category":"Animals & Nature","description":"fox","unicode_version":"9.0"},{"emoji":"🦝","aliases":["raccoon"],"tags":[],"category":"Animals & Nature","description":"raccoon","unicode_version":"11.0"},{"emoji":"🐱","aliases":["cat"],"tags":["pet"],"category":"Animals & Nature","description":"cat face","unicode_version":"6.0"},{"emoji":"🐈","aliases":["cat2"],"tags":[],"category":"Animals & Nature","description":"cat","unicode_version":"6.0"},{"emoji":"🐈‍⬛","aliases":["black_cat"],"tags":[],"category":"Animals & Nature","description":"black cat","unicode_version":"13.0"},{"emoji":"🦁","aliases":["lion"],"tags":[],"category":"Animals & Nature","description":"lion","unicode_version":"8.0"},{"emoji":"🐯","aliases":["tiger"],"tags":[],"category":"Animals & Nature","description":"tiger face","unicode_version":"6.0"},{"emoji":"🐅","aliases":["tiger2"],"tags":[],"category":"Animals & Nature","description":"tiger","unicode_version":"6.0"},{"emoji":"🐆","aliases":["leopard"],"tags":[],"category":"Animals & Nature","description":"leopard","unicode_version":"6.0"},{"emoji":"🐴","aliases":["horse"],"tags":[],"category":"Animals & Nature","description":"horse face","unicode_version":"6.0"},{"emoji":"🐎","aliases":["racehorse"],"tags":["speed"],"category":"Animals & Nature","description":"horse","unicode_version":"6.0"},{"emoji":"🦄","aliases":["unicorn"],"tags":[],"category":"Animals & Nature","description":"unicorn","unicode_version":"8.0"},{"emoji":"🦓","aliases":["zebra"],"tags":[],"category":"Animals & Nature","description":"zebra","unicode_version":"11.0"},{"emoji":"🦌","aliases":["deer"],"tags":[],"category":"Animals & Nature","description":"deer","unicode_version":"9.0"},{"emoji":"🦬","aliases":["bison"],"tags":[],"category":"Animals & Nature","description":"bison","unicode_version":"13.0"},{"emoji":"🐮","aliases":["cow"],"tags":[],"category":"Animals & Nature","description":"cow face","unicode_version":"6.0"},{"emoji":"🐂","aliases":["ox"],"tags":[],"category":"Animals & Nature","description":"ox","unicode_version":"6.0"},{"emoji":"🐃","aliases":["water_buffalo"],"tags":[],"category":"Animals & Nature","description":"water buffalo","unicode_version":"6.0"},{"emoji":"🐄","aliases":["cow2"],"tags":[],"category":"Animals & Nature","description":"cow","unicode_version":"6.0"},{"emoji":"🐷","aliases":["pig"],"tags":[],"category":"Animals & Nature","description":"pig face","unicode_version":"6.0"},{"emoji":"🐖","aliases":["pig2"],"tags":[],"category":"Animals & Nature","description":"pig","unicode_version":"6.0"},{"emoji":"🐗","aliases":["boar"],"tags":[],"category":"Animals & Nature","description":"boar","unicode_version":"6.0"},{"emoji":"🐽","aliases":["pig_nose"],"tags":[],"category":"Animals & Nature","description":"pig nose","unicode_version":"6.0"},{"emoji":"🐏","aliases":["ram"],"tags":[],"category":"Animals & Nature","description":"ram","unicode_version":"6.0"},{"emoji":"🐑","aliases":["sheep"],"tags":[],"category":"Animals & Nature","description":"ewe","unicode_version":"6.0"},{"emoji":"🐐","aliases":["goat"],"tags":[],"category":"Animals & Nature","description":"goat","unicode_version":"6.0"},{"emoji":"🐪","aliases":["dromedary_camel"],"tags":["desert"],"category":"Animals & Nature","description":"camel","unicode_version":"6.0"},{"emoji":"🐫","aliases":["camel"],"tags":[],"category":"Animals & Nature","description":"two-hump camel","unicode_version":"6.0"},{"emoji":"🦙","aliases":["llama"],"tags":[],"category":"Animals & Nature","description":"llama","unicode_version":"11.0"},{"emoji":"🦒","aliases":["giraffe"],"tags":[],"category":"Animals & Nature","description":"giraffe","unicode_version":"11.0"},{"emoji":"🐘","aliases":["elephant"],"tags":[],"category":"Animals & Nature","description":"elephant","unicode_version":"6.0"},{"emoji":"🦣","aliases":["mammoth"],"tags":[],"category":"Animals & Nature","description":"mammoth","unicode_version":"13.0"},{"emoji":"🦏","aliases":["rhinoceros"],"tags":[],"category":"Animals & Nature","description":"rhinoceros","unicode_version":"9.0"},{"emoji":"🦛","aliases":["hippopotamus"],"tags":[],"category":"Animals & Nature","description":"hippopotamus","unicode_version":"11.0"},{"emoji":"🐭","aliases":["mouse"],"tags":[],"category":"Animals & Nature","description":"mouse face","unicode_version":"6.0"},{"emoji":"🐁","aliases":["mouse2"],"tags":[],"category":"Animals & Nature","description":"mouse","unicode_version":"6.0"},{"emoji":"🐀","aliases":["rat"],"tags":[],"category":"Animals & Nature","description":"rat","unicode_version":"6.0"},{"emoji":"🐹","aliases":["hamster"],"tags":["pet"],"category":"Animals & Nature","description":"hamster","unicode_version":"6.0"},{"emoji":"🐰","aliases":["rabbit"],"tags":["bunny"],"category":"Animals & Nature","description":"rabbit face","unicode_version":"6.0"},{"emoji":"🐇","aliases":["rabbit2"],"tags":[],"category":"Animals & Nature","description":"rabbit","unicode_version":"6.0"},{"emoji":"🐿️","aliases":["chipmunk"],"tags":[],"category":"Animals & Nature","description":"chipmunk","unicode_version":"7.0"},{"emoji":"🦫","aliases":["beaver"],"tags":[],"category":"Animals & Nature","description":"beaver","unicode_version":"13.0"},{"emoji":"🦔","aliases":["hedgehog"],"tags":[],"category":"Animals & Nature","description":"hedgehog","unicode_version":"11.0"},{"emoji":"🦇","aliases":["bat"],"tags":[],"category":"Animals & Nature","description":"bat","unicode_version":"9.0"},{"emoji":"🐻","aliases":["bear"],"tags":[],"category":"Animals & Nature","description":"bear","unicode_version":"6.0"},{"emoji":"🐻‍❄️","aliases":["polar_bear"],"tags":[],"category":"Animals & Nature","description":"polar bear","unicode_version":"13.0"},{"emoji":"🐨","aliases":["koala"],"tags":[],"category":"Animals & Nature","description":"koala","unicode_version":"6.0"},{"emoji":"🐼","aliases":["panda_face"],"tags":[],"category":"Animals & Nature","description":"panda","unicode_version":"6.0"},{"emoji":"🦥","aliases":["sloth"],"tags":[],"category":"Animals & Nature","description":"sloth","unicode_version":"12.0"},{"emoji":"🦦","aliases":["otter"],"tags":[],"category":"Animals & Nature","description":"otter","unicode_version":"12.0"},{"emoji":"🦨","aliases":["skunk"],"tags":[],"category":"Animals & Nature","description":"skunk","unicode_version":"12.0"},{"emoji":"🦘","aliases":["kangaroo"],"tags":[],"category":"Animals & Nature","description":"kangaroo","unicode_version":"11.0"},{"emoji":"🦡","aliases":["badger"],"tags":[],"category":"Animals & Nature","description":"badger","unicode_version":"11.0"},{"emoji":"🐾","aliases":["feet","paw_prints"],"tags":[],"category":"Animals & Nature","description":"paw prints","unicode_version":"6.0"},{"emoji":"🦃","aliases":["turkey"],"tags":["thanksgiving"],"category":"Animals & Nature","description":"turkey","unicode_version":"8.0"},{"emoji":"🐔","aliases":["chicken"],"tags":[],"category":"Animals & Nature","description":"chicken","unicode_version":"6.0"},{"emoji":"🐓","aliases":["rooster"],"tags":[],"category":"Animals & Nature","description":"rooster","unicode_version":"6.0"},{"emoji":"🐣","aliases":["hatching_chick"],"tags":[],"category":"Animals & Nature","description":"hatching chick","unicode_version":"6.0"},{"emoji":"🐤","aliases":["baby_chick"],"tags":[],"category":"Animals & Nature","description":"baby chick","unicode_version":"6.0"},{"emoji":"🐥","aliases":["hatched_chick"],"tags":[],"category":"Animals & Nature","description":"front-facing baby chick","unicode_version":"6.0"},{"emoji":"🐦","aliases":["bird"],"tags":[],"category":"Animals & Nature","description":"bird","unicode_version":"6.0"},{"emoji":"🐧","aliases":["penguin"],"tags":[],"category":"Animals & Nature","description":"penguin","unicode_version":"6.0"},{"emoji":"🕊️","aliases":["dove"],"tags":["peace"],"category":"Animals & Nature","description":"dove","unicode_version":"7.0"},{"emoji":"🦅","aliases":["eagle"],"tags":[],"category":"Animals & Nature","description":"eagle","unicode_version":"9.0"},{"emoji":"🦆","aliases":["duck"],"tags":[],"category":"Animals & Nature","description":"duck","unicode_version":"9.0"},{"emoji":"🦢","aliases":["swan"],"tags":[],"category":"Animals & Nature","description":"swan","unicode_version":"11.0"},{"emoji":"🦉","aliases":["owl"],"tags":[],"category":"Animals & Nature","description":"owl","unicode_version":"9.0"},{"emoji":"🦤","aliases":["dodo"],"tags":[],"category":"Animals & Nature","description":"dodo","unicode_version":"13.0"},{"emoji":"🪶","aliases":["feather"],"tags":[],"category":"Animals & Nature","description":"feather","unicode_version":"13.0"},{"emoji":"🦩","aliases":["flamingo"],"tags":[],"category":"Animals & Nature","description":"flamingo","unicode_version":"12.0"},{"emoji":"🦚","aliases":["peacock"],"tags":[],"category":"Animals & Nature","description":"peacock","unicode_version":"11.0"},{"emoji":"🦜","aliases":["parrot"],"tags":[],"category":"Animals & Nature","description":"parrot","unicode_version":"11.0"},{"emoji":"🐸","aliases":["frog"],"tags":[],"category":"Animals & Nature","description":"frog","unicode_version":"6.0"},{"emoji":"🐊","aliases":["crocodile"],"tags":[],"category":"Animals & Nature","description":"crocodile","unicode_version":"6.0"},{"emoji":"🐢","aliases":["turtle"],"tags":["slow"],"category":"Animals & Nature","description":"turtle","unicode_version":"6.0"},{"emoji":"🦎","aliases":["lizard"],"tags":[],"category":"Animals & Nature","description":"lizard","unicode_version":"9.0"},{"emoji":"🐍","aliases":["snake"],"tags":[],"category":"Animals & Nature","description":"snake","unicode_version":"6.0"},{"emoji":"🐲","aliases":["dragon_face"],"tags":[],"category":"Animals & Nature","description":"dragon face","unicode_version":"6.0"},{"emoji":"🐉","aliases":["dragon"],"tags":[],"category":"Animals & Nature","description":"dragon","unicode_version":"6.0"},{"emoji":"🦕","aliases":["sauropod"],"tags":["dinosaur"],"category":"Animals & Nature","description":"sauropod","unicode_version":"11.0"},{"emoji":"🦖","aliases":["t-rex"],"tags":["dinosaur"],"category":"Animals & Nature","description":"T-Rex","unicode_version":"11.0"},{"emoji":"🐳","aliases":["whale"],"tags":["sea"],"category":"Animals & Nature","description":"spouting whale","unicode_version":"6.0"},{"emoji":"🐋","aliases":["whale2"],"tags":[],"category":"Animals & Nature","description":"whale","unicode_version":"6.0"},{"emoji":"🐬","aliases":["dolphin","flipper"],"tags":[],"category":"Animals & Nature","description":"dolphin","unicode_version":"6.0"},{"emoji":"🦭","aliases":["seal"],"tags":[],"category":"Animals & Nature","description":"seal","unicode_version":"13.0"},{"emoji":"🐟","aliases":["fish"],"tags":[],"category":"Animals & Nature","description":"fish","unicode_version":"6.0"},{"emoji":"🐠","aliases":["tropical_fish"],"tags":[],"category":"Animals & Nature","description":"tropical fish","unicode_version":"6.0"},{"emoji":"🐡","aliases":["blowfish"],"tags":[],"category":"Animals & Nature","description":"blowfish","unicode_version":"6.0"},{"emoji":"🦈","aliases":["shark"],"tags":[],"category":"Animals & Nature","description":"shark","unicode_version":"9.0"},{"emoji":"🐙","aliases":["octopus"],"tags":[],"category":"Animals & Nature","description":"octopus","unicode_version":"6.0"},{"emoji":"🐚","aliases":["shell"],"tags":["sea","beach"],"category":"Animals & Nature","description":"spiral shell","unicode_version":"6.0"},{"emoji":"🐌","aliases":["snail"],"tags":["slow"],"category":"Animals & Nature","description":"snail","unicode_version":"6.0"},{"emoji":"🦋","aliases":["butterfly"],"tags":[],"category":"Animals & Nature","description":"butterfly","unicode_version":"9.0"},{"emoji":"🐛","aliases":["bug"],"tags":[],"category":"Animals & Nature","description":"bug","unicode_version":"6.0"},{"emoji":"🐜","aliases":["ant"],"tags":[],"category":"Animals & Nature","description":"ant","unicode_version":"6.0"},{"emoji":"🐝","aliases":["bee","honeybee"],"tags":[],"category":"Animals & Nature","description":"honeybee","unicode_version":"6.0"},{"emoji":"🪲","aliases":["beetle"],"tags":[],"category":"Animals & Nature","description":"beetle","unicode_version":"13.0"},{"emoji":"🐞","aliases":["lady_beetle"],"tags":["bug"],"category":"Animals & Nature","description":"lady beetle","unicode_version":"6.0"},{"emoji":"🦗","aliases":["cricket"],"tags":[],"category":"Animals & Nature","description":"cricket","unicode_version":"11.0"},{"emoji":"🪳","aliases":["cockroach"],"tags":[],"category":"Animals & Nature","description":"cockroach","unicode_version":"13.0"},{"emoji":"🕷️","aliases":["spider"],"tags":[],"category":"Animals & Nature","description":"spider","unicode_version":"7.0"},{"emoji":"🕸️","aliases":["spider_web"],"tags":[],"category":"Animals & Nature","description":"spider web","unicode_version":"7.0"},{"emoji":"🦂","aliases":["scorpion"],"tags":[],"category":"Animals & Nature","description":"scorpion","unicode_version":"8.0"},{"emoji":"🦟","aliases":["mosquito"],"tags":[],"category":"Animals & Nature","description":"mosquito","unicode_version":"11.0"},{"emoji":"🪰","aliases":["fly"],"tags":[],"category":"Animals & Nature","description":"fly","unicode_version":"13.0"},{"emoji":"🪱","aliases":["worm"],"tags":[],"category":"Animals & Nature","description":"worm","unicode_version":"13.0"},{"emoji":"🦠","aliases":["microbe"],"tags":["germ"],"category":"Animals & Nature","description":"microbe","unicode_version":"11.0"},{"emoji":"💐","aliases":["bouquet"],"tags":["flowers"],"category":"Animals & Nature","description":"bouquet","unicode_version":"6.0"},{"emoji":"🌸","aliases":["cherry_blossom"],"tags":["flower","spring"],"category":"Animals & Nature","description":"cherry blossom","unicode_version":"6.0"},{"emoji":"💮","aliases":["white_flower"],"tags":[],"category":"Animals & Nature","description":"white flower","unicode_version":"6.0"},{"emoji":"🏵️","aliases":["rosette"],"tags":[],"category":"Animals & Nature","description":"rosette","unicode_version":"7.0"},{"emoji":"🌹","aliases":["rose"],"tags":["flower"],"category":"Animals & Nature","description":"rose","unicode_version":"6.0"},{"emoji":"🥀","aliases":["wilted_flower"],"tags":[],"category":"Animals & Nature","description":"wilted flower","unicode_version":"9.0"},{"emoji":"🌺","aliases":["hibiscus"],"tags":[],"category":"Animals & Nature","description":"hibiscus","unicode_version":"6.0"},{"emoji":"🌻","aliases":["sunflower"],"tags":[],"category":"Animals & Nature","description":"sunflower","unicode_version":"6.0"},{"emoji":"🌼","aliases":["blossom"],"tags":[],"category":"Animals & Nature","description":"blossom","unicode_version":"6.0"},{"emoji":"🌷","aliases":["tulip"],"tags":["flower"],"category":"Animals & Nature","description":"tulip","unicode_version":"6.0"},{"emoji":"🌱","aliases":["seedling"],"tags":["plant"],"category":"Animals & Nature","description":"seedling","unicode_version":"6.0"},{"emoji":"🪴","aliases":["potted_plant"],"tags":[],"category":"Animals & Nature","description":"potted plant","unicode_version":"13.0"},{"emoji":"🌲","aliases":["evergreen_tree"],"tags":["wood"],"category":"Animals & Nature","description":"evergreen tree","unicode_version":"6.0"},{"emoji":"🌳","aliases":["deciduous_tree"],"tags":["wood"],"category":"Animals & Nature","description":"deciduous tree","unicode_version":"6.0"},{"emoji":"🌴","aliases":["palm_tree"],"tags":[],"category":"Animals & Nature","description":"palm tree","unicode_version":"6.0"},{"emoji":"🌵","aliases":["cactus"],"tags":[],"category":"Animals & Nature","description":"cactus","unicode_version":"6.0"},{"emoji":"🌾","aliases":["ear_of_rice"],"tags":[],"category":"Animals & Nature","description":"sheaf of rice","unicode_version":"6.0"},{"emoji":"🌿","aliases":["herb"],"tags":[],"category":"Animals & Nature","description":"herb","unicode_version":"6.0"},{"emoji":"☘️","aliases":["shamrock"],"tags":[],"category":"Animals & Nature","description":"shamrock","unicode_version":"4.1"},{"emoji":"🍀","aliases":["four_leaf_clover"],"tags":["luck"],"category":"Animals & Nature","description":"four leaf clover","unicode_version":"6.0"},{"emoji":"🍁","aliases":["maple_leaf"],"tags":["canada"],"category":"Animals & Nature","description":"maple leaf","unicode_version":"6.0"},{"emoji":"🍂","aliases":["fallen_leaf"],"tags":["autumn"],"category":"Animals & Nature","description":"fallen leaf","unicode_version":"6.0"},{"emoji":"🍃","aliases":["leaves"],"tags":["leaf"],"category":"Animals & Nature","description":"leaf fluttering in wind","unicode_version":"6.0"},{"emoji":"🍇","aliases":["grapes"],"tags":[],"category":"Food & Drink","description":"grapes","unicode_version":"6.0"},{"emoji":"🍈","aliases":["melon"],"tags":[],"category":"Food & Drink","description":"melon","unicode_version":"6.0"},{"emoji":"🍉","aliases":["watermelon"],"tags":[],"category":"Food & Drink","description":"watermelon","unicode_version":"6.0"},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"],"tags":[],"category":"Food & Drink","description":"tangerine","unicode_version":"6.0"},{"emoji":"🍋","aliases":["lemon"],"tags":[],"category":"Food & Drink","description":"lemon","unicode_version":"6.0"},{"emoji":"🍌","aliases":["banana"],"tags":["fruit"],"category":"Food & Drink","description":"banana","unicode_version":"6.0"},{"emoji":"🍍","aliases":["pineapple"],"tags":[],"category":"Food & Drink","description":"pineapple","unicode_version":"6.0"},{"emoji":"🥭","aliases":["mango"],"tags":[],"category":"Food & Drink","description":"mango","unicode_version":"11.0"},{"emoji":"🍎","aliases":["apple"],"tags":[],"category":"Food & Drink","description":"red apple","unicode_version":"6.0"},{"emoji":"🍏","aliases":["green_apple"],"tags":["fruit"],"category":"Food & Drink","description":"green apple","unicode_version":"6.0"},{"emoji":"🍐","aliases":["pear"],"tags":[],"category":"Food & Drink","description":"pear","unicode_version":"6.0"},{"emoji":"🍑","aliases":["peach"],"tags":[],"category":"Food & Drink","description":"peach","unicode_version":"6.0"},{"emoji":"🍒","aliases":["cherries"],"tags":["fruit"],"category":"Food & Drink","description":"cherries","unicode_version":"6.0"},{"emoji":"🍓","aliases":["strawberry"],"tags":["fruit"],"category":"Food & Drink","description":"strawberry","unicode_version":"6.0"},{"emoji":"🫐","aliases":["blueberries"],"tags":[],"category":"Food & Drink","description":"blueberries","unicode_version":"13.0"},{"emoji":"🥝","aliases":["kiwi_fruit"],"tags":[],"category":"Food & Drink","description":"kiwi fruit","unicode_version":"9.0"},{"emoji":"🍅","aliases":["tomato"],"tags":[],"category":"Food & Drink","description":"tomato","unicode_version":"6.0"},{"emoji":"🫒","aliases":["olive"],"tags":[],"category":"Food & Drink","description":"olive","unicode_version":"13.0"},{"emoji":"🥥","aliases":["coconut"],"tags":[],"category":"Food & Drink","description":"coconut","unicode_version":"11.0"},{"emoji":"🥑","aliases":["avocado"],"tags":[],"category":"Food & Drink","description":"avocado","unicode_version":"9.0"},{"emoji":"🍆","aliases":["eggplant"],"tags":["aubergine"],"category":"Food & Drink","description":"eggplant","unicode_version":"6.0"},{"emoji":"🥔","aliases":["potato"],"tags":[],"category":"Food & Drink","description":"potato","unicode_version":"9.0"},{"emoji":"🥕","aliases":["carrot"],"tags":[],"category":"Food & Drink","description":"carrot","unicode_version":"9.0"},{"emoji":"🌽","aliases":["corn"],"tags":[],"category":"Food & Drink","description":"ear of corn","unicode_version":"6.0"},{"emoji":"🌶️","aliases":["hot_pepper"],"tags":["spicy"],"category":"Food & Drink","description":"hot pepper","unicode_version":"7.0"},{"emoji":"🫑","aliases":["bell_pepper"],"tags":[],"category":"Food & Drink","description":"bell pepper","unicode_version":"13.0"},{"emoji":"🥒","aliases":["cucumber"],"tags":[],"category":"Food & Drink","description":"cucumber","unicode_version":"9.0"},{"emoji":"🥬","aliases":["leafy_green"],"tags":[],"category":"Food & Drink","description":"leafy green","unicode_version":"11.0"},{"emoji":"🥦","aliases":["broccoli"],"tags":[],"category":"Food & Drink","description":"broccoli","unicode_version":"11.0"},{"emoji":"🧄","aliases":["garlic"],"tags":[],"category":"Food & Drink","description":"garlic","unicode_version":"12.0"},{"emoji":"🧅","aliases":["onion"],"tags":[],"category":"Food & Drink","description":"onion","unicode_version":"12.0"},{"emoji":"🍄","aliases":["mushroom"],"tags":[],"category":"Food & Drink","description":"mushroom","unicode_version":"6.0"},{"emoji":"🥜","aliases":["peanuts"],"tags":[],"category":"Food & Drink","description":"peanuts","unicode_version":"9.0"},{"emoji":"🌰","aliases":["chestnut"],"tags":[],"category":"Food & Drink","description":"chestnut","unicode_version":"6.0"},{"emoji":"🍞","aliases":["bread"],"tags":["toast"],"category":"Food & Drink","description":"bread","unicode_version":"6.0"},{"emoji":"🥐","aliases":["croissant"],"tags":[],"category":"Food & Drink","description":"croissant","unicode_version":"9.0"},{"emoji":"🥖","aliases":["baguette_bread"],"tags":[],"category":"Food & Drink","description":"baguette bread","unicode_version":"9.0"},{"emoji":"🫓","aliases":["flatbread"],"tags":[],"category":"Food & Drink","description":"flatbread","unicode_version":"13.0"},{"emoji":"🥨","aliases":["pretzel"],"tags":[],"category":"Food & Drink","description":"pretzel","unicode_version":"11.0"},{"emoji":"🥯","aliases":["bagel"],"tags":[],"category":"Food & Drink","description":"bagel","unicode_version":"11.0"},{"emoji":"🥞","aliases":["pancakes"],"tags":[],"category":"Food & Drink","description":"pancakes","unicode_version":"9.0"},{"emoji":"🧇","aliases":["waffle"],"tags":[],"category":"Food & Drink","description":"waffle","unicode_version":"12.0"},{"emoji":"🧀","aliases":["cheese"],"tags":[],"category":"Food & Drink","description":"cheese wedge","unicode_version":"8.0"},{"emoji":"🍖","aliases":["meat_on_bone"],"tags":[],"category":"Food & Drink","description":"meat on bone","unicode_version":"6.0"},{"emoji":"🍗","aliases":["poultry_leg"],"tags":["meat","chicken"],"category":"Food & Drink","description":"poultry leg","unicode_version":"6.0"},{"emoji":"🥩","aliases":["cut_of_meat"],"tags":[],"category":"Food & Drink","description":"cut of meat","unicode_version":"11.0"},{"emoji":"🥓","aliases":["bacon"],"tags":[],"category":"Food & Drink","description":"bacon","unicode_version":"9.0"},{"emoji":"🍔","aliases":["hamburger"],"tags":["burger"],"category":"Food & Drink","description":"hamburger","unicode_version":"6.0"},{"emoji":"🍟","aliases":["fries"],"tags":[],"category":"Food & Drink","description":"french fries","unicode_version":"6.0"},{"emoji":"🍕","aliases":["pizza"],"tags":[],"category":"Food & Drink","description":"pizza","unicode_version":"6.0"},{"emoji":"🌭","aliases":["hotdog"],"tags":[],"category":"Food & Drink","description":"hot dog","unicode_version":"8.0"},{"emoji":"🥪","aliases":["sandwich"],"tags":[],"category":"Food & Drink","description":"sandwich","unicode_version":"11.0"},{"emoji":"🌮","aliases":["taco"],"tags":[],"category":"Food & Drink","description":"taco","unicode_version":"8.0"},{"emoji":"🌯","aliases":["burrito"],"tags":[],"category":"Food & Drink","description":"burrito","unicode_version":"8.0"},{"emoji":"🫔","aliases":["tamale"],"tags":[],"category":"Food & Drink","description":"tamale","unicode_version":"13.0"},{"emoji":"🥙","aliases":["stuffed_flatbread"],"tags":[],"category":"Food & Drink","description":"stuffed flatbread","unicode_version":"9.0"},{"emoji":"🧆","aliases":["falafel"],"tags":[],"category":"Food & Drink","description":"falafel","unicode_version":"12.0"},{"emoji":"🥚","aliases":["egg"],"tags":[],"category":"Food & Drink","description":"egg","unicode_version":"9.0"},{"emoji":"🍳","aliases":["fried_egg"],"tags":["breakfast"],"category":"Food & Drink","description":"cooking","unicode_version":"6.0"},{"emoji":"🥘","aliases":["shallow_pan_of_food"],"tags":["paella","curry"],"category":"Food & Drink","description":"shallow pan of food","unicode_version":""},{"emoji":"🍲","aliases":["stew"],"tags":[],"category":"Food & Drink","description":"pot of food","unicode_version":"6.0"},{"emoji":"🫕","aliases":["fondue"],"tags":[],"category":"Food & Drink","description":"fondue","unicode_version":"13.0"},{"emoji":"🥣","aliases":["bowl_with_spoon"],"tags":[],"category":"Food & Drink","description":"bowl with spoon","unicode_version":"11.0"},{"emoji":"🥗","aliases":["green_salad"],"tags":[],"category":"Food & Drink","description":"green salad","unicode_version":"9.0"},{"emoji":"🍿","aliases":["popcorn"],"tags":[],"category":"Food & Drink","description":"popcorn","unicode_version":"8.0"},{"emoji":"🧈","aliases":["butter"],"tags":[],"category":"Food & Drink","description":"butter","unicode_version":"12.0"},{"emoji":"🧂","aliases":["salt"],"tags":[],"category":"Food & Drink","description":"salt","unicode_version":"11.0"},{"emoji":"🥫","aliases":["canned_food"],"tags":[],"category":"Food & Drink","description":"canned food","unicode_version":"11.0"},{"emoji":"🍱","aliases":["bento"],"tags":[],"category":"Food & Drink","description":"bento box","unicode_version":"6.0"},{"emoji":"🍘","aliases":["rice_cracker"],"tags":[],"category":"Food & Drink","description":"rice cracker","unicode_version":"6.0"},{"emoji":"🍙","aliases":["rice_ball"],"tags":[],"category":"Food & Drink","description":"rice ball","unicode_version":"6.0"},{"emoji":"🍚","aliases":["rice"],"tags":[],"category":"Food & Drink","description":"cooked rice","unicode_version":"6.0"},{"emoji":"🍛","aliases":["curry"],"tags":[],"category":"Food & Drink","description":"curry rice","unicode_version":"6.0"},{"emoji":"🍜","aliases":["ramen"],"tags":["noodle"],"category":"Food & Drink","description":"steaming bowl","unicode_version":"6.0"},{"emoji":"🍝","aliases":["spaghetti"],"tags":["pasta"],"category":"Food & Drink","description":"spaghetti","unicode_version":"6.0"},{"emoji":"🍠","aliases":["sweet_potato"],"tags":[],"category":"Food & Drink","description":"roasted sweet potato","unicode_version":"6.0"},{"emoji":"🍢","aliases":["oden"],"tags":[],"category":"Food & Drink","description":"oden","unicode_version":"6.0"},{"emoji":"🍣","aliases":["sushi"],"tags":[],"category":"Food & Drink","description":"sushi","unicode_version":"6.0"},{"emoji":"🍤","aliases":["fried_shrimp"],"tags":["tempura"],"category":"Food & Drink","description":"fried shrimp","unicode_version":"6.0"},{"emoji":"🍥","aliases":["fish_cake"],"tags":[],"category":"Food & Drink","description":"fish cake with swirl","unicode_version":"6.0"},{"emoji":"🥮","aliases":["moon_cake"],"tags":[],"category":"Food & Drink","description":"moon cake","unicode_version":"11.0"},{"emoji":"🍡","aliases":["dango"],"tags":[],"category":"Food & Drink","description":"dango","unicode_version":"6.0"},{"emoji":"🥟","aliases":["dumpling"],"tags":[],"category":"Food & Drink","description":"dumpling","unicode_version":"11.0"},{"emoji":"🥠","aliases":["fortune_cookie"],"tags":[],"category":"Food & Drink","description":"fortune cookie","unicode_version":"11.0"},{"emoji":"🥡","aliases":["takeout_box"],"tags":[],"category":"Food & Drink","description":"takeout box","unicode_version":"11.0"},{"emoji":"🦀","aliases":["crab"],"tags":[],"category":"Food & Drink","description":"crab","unicode_version":"8.0"},{"emoji":"🦞","aliases":["lobster"],"tags":[],"category":"Food & Drink","description":"lobster","unicode_version":"11.0"},{"emoji":"🦐","aliases":["shrimp"],"tags":[],"category":"Food & Drink","description":"shrimp","unicode_version":"9.0"},{"emoji":"🦑","aliases":["squid"],"tags":[],"category":"Food & Drink","description":"squid","unicode_version":"9.0"},{"emoji":"🦪","aliases":["oyster"],"tags":[],"category":"Food & Drink","description":"oyster","unicode_version":"12.0"},{"emoji":"🍦","aliases":["icecream"],"tags":[],"category":"Food & Drink","description":"soft ice cream","unicode_version":"6.0"},{"emoji":"🍧","aliases":["shaved_ice"],"tags":[],"category":"Food & Drink","description":"shaved ice","unicode_version":"6.0"},{"emoji":"🍨","aliases":["ice_cream"],"tags":[],"category":"Food & Drink","description":"ice cream","unicode_version":"6.0"},{"emoji":"🍩","aliases":["doughnut"],"tags":[],"category":"Food & Drink","description":"doughnut","unicode_version":"6.0"},{"emoji":"🍪","aliases":["cookie"],"tags":[],"category":"Food & Drink","description":"cookie","unicode_version":"6.0"},{"emoji":"🎂","aliases":["birthday"],"tags":["party"],"category":"Food & Drink","description":"birthday cake","unicode_version":"6.0"},{"emoji":"🍰","aliases":["cake"],"tags":["dessert"],"category":"Food & Drink","description":"shortcake","unicode_version":"6.0"},{"emoji":"🧁","aliases":["cupcake"],"tags":[],"category":"Food & Drink","description":"cupcake","unicode_version":"11.0"},{"emoji":"🥧","aliases":["pie"],"tags":[],"category":"Food & Drink","description":"pie","unicode_version":"11.0"},{"emoji":"🍫","aliases":["chocolate_bar"],"tags":[],"category":"Food & Drink","description":"chocolate bar","unicode_version":"6.0"},{"emoji":"🍬","aliases":["candy"],"tags":["sweet"],"category":"Food & Drink","description":"candy","unicode_version":"6.0"},{"emoji":"🍭","aliases":["lollipop"],"tags":[],"category":"Food & Drink","description":"lollipop","unicode_version":"6.0"},{"emoji":"🍮","aliases":["custard"],"tags":[],"category":"Food & Drink","description":"custard","unicode_version":"6.0"},{"emoji":"🍯","aliases":["honey_pot"],"tags":[],"category":"Food & Drink","description":"honey pot","unicode_version":"6.0"},{"emoji":"🍼","aliases":["baby_bottle"],"tags":["milk"],"category":"Food & Drink","description":"baby bottle","unicode_version":"6.0"},{"emoji":"🥛","aliases":["milk_glass"],"tags":[],"category":"Food & Drink","description":"glass of milk","unicode_version":"9.0"},{"emoji":"☕","aliases":["coffee"],"tags":["cafe","espresso"],"category":"Food & Drink","description":"hot beverage","unicode_version":"4.0"},{"emoji":"🫖","aliases":["teapot"],"tags":[],"category":"Food & Drink","description":"teapot","unicode_version":"13.0"},{"emoji":"🍵","aliases":["tea"],"tags":["green","breakfast"],"category":"Food & Drink","description":"teacup without handle","unicode_version":"6.0"},{"emoji":"🍶","aliases":["sake"],"tags":[],"category":"Food & Drink","description":"sake","unicode_version":"6.0"},{"emoji":"🍾","aliases":["champagne"],"tags":["bottle","bubbly","celebration"],"category":"Food & Drink","description":"bottle with popping cork","unicode_version":"8.0"},{"emoji":"🍷","aliases":["wine_glass"],"tags":[],"category":"Food & Drink","description":"wine glass","unicode_version":"6.0"},{"emoji":"🍸","aliases":["cocktail"],"tags":["drink"],"category":"Food & Drink","description":"cocktail glass","unicode_version":"6.0"},{"emoji":"🍹","aliases":["tropical_drink"],"tags":["summer","vacation"],"category":"Food & Drink","description":"tropical drink","unicode_version":"6.0"},{"emoji":"🍺","aliases":["beer"],"tags":["drink"],"category":"Food & Drink","description":"beer mug","unicode_version":"6.0"},{"emoji":"🍻","aliases":["beers"],"tags":["drinks"],"category":"Food & Drink","description":"clinking beer mugs","unicode_version":"6.0"},{"emoji":"🥂","aliases":["clinking_glasses"],"tags":["cheers","toast"],"category":"Food & Drink","description":"clinking glasses","unicode_version":"9.0"},{"emoji":"🥃","aliases":["tumbler_glass"],"tags":["whisky"],"category":"Food & Drink","description":"tumbler glass","unicode_version":"9.0"},{"emoji":"🥤","aliases":["cup_with_straw"],"tags":[],"category":"Food & Drink","description":"cup with straw","unicode_version":"11.0"},{"emoji":"🧋","aliases":["bubble_tea"],"tags":[],"category":"Food & Drink","description":"bubble tea","unicode_version":"13.0"},{"emoji":"🧃","aliases":["beverage_box"],"tags":[],"category":"Food & Drink","description":"beverage box","unicode_version":"12.0"},{"emoji":"🧉","aliases":["mate"],"tags":[],"category":"Food & Drink","description":"mate","unicode_version":"12.0"},{"emoji":"🧊","aliases":["ice_cube"],"tags":[],"category":"Food & Drink","description":"ice","unicode_version":"12.0"},{"emoji":"🥢","aliases":["chopsticks"],"tags":[],"category":"Food & Drink","description":"chopsticks","unicode_version":"11.0"},{"emoji":"🍽️","aliases":["plate_with_cutlery"],"tags":["dining","dinner"],"category":"Food & Drink","description":"fork and knife with plate","unicode_version":"7.0"},{"emoji":"🍴","aliases":["fork_and_knife"],"tags":["cutlery"],"category":"Food & Drink","description":"fork and knife","unicode_version":"6.0"},{"emoji":"🥄","aliases":["spoon"],"tags":[],"category":"Food & Drink","description":"spoon","unicode_version":"9.0"},{"emoji":"🔪","aliases":["hocho","knife"],"tags":["cut","chop"],"category":"Food & Drink","description":"kitchen knife","unicode_version":"6.0"},{"emoji":"🏺","aliases":["amphora"],"tags":[],"category":"Food & Drink","description":"amphora","unicode_version":"8.0"},{"emoji":"🌍","aliases":["earth_africa"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Europe-Africa","unicode_version":"6.0"},{"emoji":"🌎","aliases":["earth_americas"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Americas","unicode_version":"6.0"},{"emoji":"🌏","aliases":["earth_asia"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Asia-Australia","unicode_version":"6.0"},{"emoji":"🌐","aliases":["globe_with_meridians"],"tags":["world","global","international"],"category":"Travel & Places","description":"globe with meridians","unicode_version":"6.0"},{"emoji":"🗺️","aliases":["world_map"],"tags":["travel"],"category":"Travel & Places","description":"world map","unicode_version":"7.0"},{"emoji":"🗾","aliases":["japan"],"tags":[],"category":"Travel & Places","description":"map of Japan","unicode_version":"6.0"},{"emoji":"🧭","aliases":["compass"],"tags":[],"category":"Travel & Places","description":"compass","unicode_version":"11.0"},{"emoji":"🏔️","aliases":["mountain_snow"],"tags":[],"category":"Travel & Places","description":"snow-capped mountain","unicode_version":"7.0"},{"emoji":"⛰️","aliases":["mountain"],"tags":[],"category":"Travel & Places","description":"mountain","unicode_version":"5.2"},{"emoji":"🌋","aliases":["volcano"],"tags":[],"category":"Travel & Places","description":"volcano","unicode_version":"6.0"},{"emoji":"🗻","aliases":["mount_fuji"],"tags":[],"category":"Travel & Places","description":"mount fuji","unicode_version":"6.0"},{"emoji":"🏕️","aliases":["camping"],"tags":[],"category":"Travel & Places","description":"camping","unicode_version":"7.0"},{"emoji":"🏖️","aliases":["beach_umbrella"],"tags":[],"category":"Travel & Places","description":"beach with umbrella","unicode_version":"7.0"},{"emoji":"🏜️","aliases":["desert"],"tags":[],"category":"Travel & Places","description":"desert","unicode_version":"7.0"},{"emoji":"🏝️","aliases":["desert_island"],"tags":[],"category":"Travel & Places","description":"desert island","unicode_version":"7.0"},{"emoji":"🏞️","aliases":["national_park"],"tags":[],"category":"Travel & Places","description":"national park","unicode_version":"7.0"},{"emoji":"🏟️","aliases":["stadium"],"tags":[],"category":"Travel & Places","description":"stadium","unicode_version":"7.0"},{"emoji":"🏛️","aliases":["classical_building"],"tags":[],"category":"Travel & Places","description":"classical building","unicode_version":"7.0"},{"emoji":"🏗️","aliases":["building_construction"],"tags":[],"category":"Travel & Places","description":"building construction","unicode_version":"7.0"},{"emoji":"🧱","aliases":["bricks"],"tags":[],"category":"Travel & Places","description":"brick","unicode_version":"11.0"},{"emoji":"🪨","aliases":["rock"],"tags":[],"category":"Travel & Places","description":"rock","unicode_version":"13.0"},{"emoji":"🪵","aliases":["wood"],"tags":[],"category":"Travel & Places","description":"wood","unicode_version":"13.0"},{"emoji":"🛖","aliases":["hut"],"tags":[],"category":"Travel & Places","description":"hut","unicode_version":"13.0"},{"emoji":"🏘️","aliases":["houses"],"tags":[],"category":"Travel & Places","description":"houses","unicode_version":"7.0"},{"emoji":"🏚️","aliases":["derelict_house"],"tags":[],"category":"Travel & Places","description":"derelict house","unicode_version":"7.0"},{"emoji":"🏠","aliases":["house"],"tags":[],"category":"Travel & Places","description":"house","unicode_version":"6.0"},{"emoji":"🏡","aliases":["house_with_garden"],"tags":[],"category":"Travel & Places","description":"house with garden","unicode_version":"6.0"},{"emoji":"🏢","aliases":["office"],"tags":[],"category":"Travel & Places","description":"office building","unicode_version":"6.0"},{"emoji":"🏣","aliases":["post_office"],"tags":[],"category":"Travel & Places","description":"Japanese post office","unicode_version":"6.0"},{"emoji":"🏤","aliases":["european_post_office"],"tags":[],"category":"Travel & Places","description":"post office","unicode_version":"6.0"},{"emoji":"🏥","aliases":["hospital"],"tags":[],"category":"Travel & Places","description":"hospital","unicode_version":"6.0"},{"emoji":"🏦","aliases":["bank"],"tags":[],"category":"Travel & Places","description":"bank","unicode_version":"6.0"},{"emoji":"🏨","aliases":["hotel"],"tags":[],"category":"Travel & Places","description":"hotel","unicode_version":"6.0"},{"emoji":"🏩","aliases":["love_hotel"],"tags":[],"category":"Travel & Places","description":"love hotel","unicode_version":"6.0"},{"emoji":"🏪","aliases":["convenience_store"],"tags":[],"category":"Travel & Places","description":"convenience store","unicode_version":"6.0"},{"emoji":"🏫","aliases":["school"],"tags":[],"category":"Travel & Places","description":"school","unicode_version":"6.0"},{"emoji":"🏬","aliases":["department_store"],"tags":[],"category":"Travel & Places","description":"department store","unicode_version":"6.0"},{"emoji":"🏭","aliases":["factory"],"tags":[],"category":"Travel & Places","description":"factory","unicode_version":"6.0"},{"emoji":"🏯","aliases":["japanese_castle"],"tags":[],"category":"Travel & Places","description":"Japanese castle","unicode_version":"6.0"},{"emoji":"🏰","aliases":["european_castle"],"tags":[],"category":"Travel & Places","description":"castle","unicode_version":"6.0"},{"emoji":"💒","aliases":["wedding"],"tags":["marriage"],"category":"Travel & Places","description":"wedding","unicode_version":"6.0"},{"emoji":"🗼","aliases":["tokyo_tower"],"tags":[],"category":"Travel & Places","description":"Tokyo tower","unicode_version":"6.0"},{"emoji":"🗽","aliases":["statue_of_liberty"],"tags":[],"category":"Travel & Places","description":"Statue of Liberty","unicode_version":"6.0"},{"emoji":"⛪","aliases":["church"],"tags":[],"category":"Travel & Places","description":"church","unicode_version":"5.2"},{"emoji":"🕌","aliases":["mosque"],"tags":[],"category":"Travel & Places","description":"mosque","unicode_version":"8.0"},{"emoji":"🛕","aliases":["hindu_temple"],"tags":[],"category":"Travel & Places","description":"hindu temple","unicode_version":"12.0"},{"emoji":"🕍","aliases":["synagogue"],"tags":[],"category":"Travel & Places","description":"synagogue","unicode_version":"8.0"},{"emoji":"⛩️","aliases":["shinto_shrine"],"tags":[],"category":"Travel & Places","description":"shinto shrine","unicode_version":"5.2"},{"emoji":"🕋","aliases":["kaaba"],"tags":[],"category":"Travel & Places","description":"kaaba","unicode_version":"8.0"},{"emoji":"⛲","aliases":["fountain"],"tags":[],"category":"Travel & Places","description":"fountain","unicode_version":"5.2"},{"emoji":"⛺","aliases":["tent"],"tags":["camping"],"category":"Travel & Places","description":"tent","unicode_version":"5.2"},{"emoji":"🌁","aliases":["foggy"],"tags":["karl"],"category":"Travel & Places","description":"foggy","unicode_version":"6.0"},{"emoji":"🌃","aliases":["night_with_stars"],"tags":[],"category":"Travel & Places","description":"night with stars","unicode_version":"6.0"},{"emoji":"🏙️","aliases":["cityscape"],"tags":["skyline"],"category":"Travel & Places","description":"cityscape","unicode_version":"7.0"},{"emoji":"🌄","aliases":["sunrise_over_mountains"],"tags":[],"category":"Travel & Places","description":"sunrise over mountains","unicode_version":"6.0"},{"emoji":"🌅","aliases":["sunrise"],"tags":[],"category":"Travel & Places","description":"sunrise","unicode_version":"6.0"},{"emoji":"🌆","aliases":["city_sunset"],"tags":[],"category":"Travel & Places","description":"cityscape at dusk","unicode_version":"6.0"},{"emoji":"🌇","aliases":["city_sunrise"],"tags":[],"category":"Travel & Places","description":"sunset","unicode_version":"6.0"},{"emoji":"🌉","aliases":["bridge_at_night"],"tags":[],"category":"Travel & Places","description":"bridge at night","unicode_version":"6.0"},{"emoji":"♨️","aliases":["hotsprings"],"tags":[],"category":"Travel & Places","description":"hot springs","unicode_version":""},{"emoji":"🎠","aliases":["carousel_horse"],"tags":[],"category":"Travel & Places","description":"carousel horse","unicode_version":"6.0"},{"emoji":"🎡","aliases":["ferris_wheel"],"tags":[],"category":"Travel & Places","description":"ferris wheel","unicode_version":"6.0"},{"emoji":"🎢","aliases":["roller_coaster"],"tags":[],"category":"Travel & Places","description":"roller coaster","unicode_version":"6.0"},{"emoji":"💈","aliases":["barber"],"tags":[],"category":"Travel & Places","description":"barber pole","unicode_version":"6.0"},{"emoji":"🎪","aliases":["circus_tent"],"tags":[],"category":"Travel & Places","description":"circus tent","unicode_version":"6.0"},{"emoji":"🚂","aliases":["steam_locomotive"],"tags":["train"],"category":"Travel & Places","description":"locomotive","unicode_version":"6.0"},{"emoji":"🚃","aliases":["railway_car"],"tags":[],"category":"Travel & Places","description":"railway car","unicode_version":"6.0"},{"emoji":"🚄","aliases":["bullettrain_side"],"tags":["train"],"category":"Travel & Places","description":"high-speed train","unicode_version":"6.0"},{"emoji":"🚅","aliases":["bullettrain_front"],"tags":["train"],"category":"Travel & Places","description":"bullet train","unicode_version":"6.0"},{"emoji":"🚆","aliases":["train2"],"tags":[],"category":"Travel & Places","description":"train","unicode_version":"6.0"},{"emoji":"🚇","aliases":["metro"],"tags":[],"category":"Travel & Places","description":"metro","unicode_version":"6.0"},{"emoji":"🚈","aliases":["light_rail"],"tags":[],"category":"Travel & Places","description":"light rail","unicode_version":"6.0"},{"emoji":"🚉","aliases":["station"],"tags":[],"category":"Travel & Places","description":"station","unicode_version":"6.0"},{"emoji":"🚊","aliases":["tram"],"tags":[],"category":"Travel & Places","description":"tram","unicode_version":"6.0"},{"emoji":"🚝","aliases":["monorail"],"tags":[],"category":"Travel & Places","description":"monorail","unicode_version":"6.0"},{"emoji":"🚞","aliases":["mountain_railway"],"tags":[],"category":"Travel & Places","description":"mountain railway","unicode_version":"6.0"},{"emoji":"🚋","aliases":["train"],"tags":[],"category":"Travel & Places","description":"tram car","unicode_version":"6.0"},{"emoji":"🚌","aliases":["bus"],"tags":[],"category":"Travel & Places","description":"bus","unicode_version":"6.0"},{"emoji":"🚍","aliases":["oncoming_bus"],"tags":[],"category":"Travel & Places","description":"oncoming bus","unicode_version":"6.0"},{"emoji":"🚎","aliases":["trolleybus"],"tags":[],"category":"Travel & Places","description":"trolleybus","unicode_version":"6.0"},{"emoji":"🚐","aliases":["minibus"],"tags":[],"category":"Travel & Places","description":"minibus","unicode_version":"6.0"},{"emoji":"🚑","aliases":["ambulance"],"tags":[],"category":"Travel & Places","description":"ambulance","unicode_version":"6.0"},{"emoji":"🚒","aliases":["fire_engine"],"tags":[],"category":"Travel & Places","description":"fire engine","unicode_version":"6.0"},{"emoji":"🚓","aliases":["police_car"],"tags":[],"category":"Travel & Places","description":"police car","unicode_version":"6.0"},{"emoji":"🚔","aliases":["oncoming_police_car"],"tags":[],"category":"Travel & Places","description":"oncoming police car","unicode_version":"6.0"},{"emoji":"🚕","aliases":["taxi"],"tags":[],"category":"Travel & Places","description":"taxi","unicode_version":"6.0"},{"emoji":"🚖","aliases":["oncoming_taxi"],"tags":[],"category":"Travel & Places","description":"oncoming taxi","unicode_version":"6.0"},{"emoji":"🚗","aliases":["car","red_car"],"tags":[],"category":"Travel & Places","description":"automobile","unicode_version":"6.0"},{"emoji":"🚘","aliases":["oncoming_automobile"],"tags":[],"category":"Travel & Places","description":"oncoming automobile","unicode_version":"6.0"},{"emoji":"🚙","aliases":["blue_car"],"tags":[],"category":"Travel & Places","description":"sport utility vehicle","unicode_version":"6.0"},{"emoji":"🛻","aliases":["pickup_truck"],"tags":[],"category":"Travel & Places","description":"pickup truck","unicode_version":"13.0"},{"emoji":"🚚","aliases":["truck"],"tags":[],"category":"Travel & Places","description":"delivery truck","unicode_version":"6.0"},{"emoji":"🚛","aliases":["articulated_lorry"],"tags":[],"category":"Travel & Places","description":"articulated lorry","unicode_version":"6.0"},{"emoji":"🚜","aliases":["tractor"],"tags":[],"category":"Travel & Places","description":"tractor","unicode_version":"6.0"},{"emoji":"🏎️","aliases":["racing_car"],"tags":[],"category":"Travel & Places","description":"racing car","unicode_version":"7.0"},{"emoji":"🏍️","aliases":["motorcycle"],"tags":[],"category":"Travel & Places","description":"motorcycle","unicode_version":"7.0"},{"emoji":"🛵","aliases":["motor_scooter"],"tags":[],"category":"Travel & Places","description":"motor scooter","unicode_version":"9.0"},{"emoji":"🦽","aliases":["manual_wheelchair"],"tags":[],"category":"Travel & Places","description":"manual wheelchair","unicode_version":"12.0"},{"emoji":"🦼","aliases":["motorized_wheelchair"],"tags":[],"category":"Travel & Places","description":"motorized wheelchair","unicode_version":"12.0"},{"emoji":"🛺","aliases":["auto_rickshaw"],"tags":[],"category":"Travel & Places","description":"auto rickshaw","unicode_version":"12.0"},{"emoji":"🚲","aliases":["bike"],"tags":["bicycle"],"category":"Travel & Places","description":"bicycle","unicode_version":"6.0"},{"emoji":"🛴","aliases":["kick_scooter"],"tags":[],"category":"Travel & Places","description":"kick scooter","unicode_version":"9.0"},{"emoji":"🛹","aliases":["skateboard"],"tags":[],"category":"Travel & Places","description":"skateboard","unicode_version":"11.0"},{"emoji":"🛼","aliases":["roller_skate"],"tags":[],"category":"Travel & Places","description":"roller skate","unicode_version":"13.0"},{"emoji":"🚏","aliases":["busstop"],"tags":[],"category":"Travel & Places","description":"bus stop","unicode_version":"6.0"},{"emoji":"🛣️","aliases":["motorway"],"tags":[],"category":"Travel & Places","description":"motorway","unicode_version":"7.0"},{"emoji":"🛤️","aliases":["railway_track"],"tags":[],"category":"Travel & Places","description":"railway track","unicode_version":"7.0"},{"emoji":"🛢️","aliases":["oil_drum"],"tags":[],"category":"Travel & Places","description":"oil drum","unicode_version":"7.0"},{"emoji":"⛽","aliases":["fuelpump"],"tags":[],"category":"Travel & Places","description":"fuel pump","unicode_version":"5.2"},{"emoji":"🚨","aliases":["rotating_light"],"tags":["911","emergency"],"category":"Travel & Places","description":"police car light","unicode_version":"6.0"},{"emoji":"🚥","aliases":["traffic_light"],"tags":[],"category":"Travel & Places","description":"horizontal traffic light","unicode_version":"6.0"},{"emoji":"🚦","aliases":["vertical_traffic_light"],"tags":["semaphore"],"category":"Travel & Places","description":"vertical traffic light","unicode_version":"6.0"},{"emoji":"🛑","aliases":["stop_sign"],"tags":[],"category":"Travel & Places","description":"stop sign","unicode_version":"9.0"},{"emoji":"🚧","aliases":["construction"],"tags":["wip"],"category":"Travel & Places","description":"construction","unicode_version":"6.0"},{"emoji":"⚓","aliases":["anchor"],"tags":["ship"],"category":"Travel & Places","description":"anchor","unicode_version":"4.1"},{"emoji":"⛵","aliases":["boat","sailboat"],"tags":[],"category":"Travel & Places","description":"sailboat","unicode_version":"5.2"},{"emoji":"🛶","aliases":["canoe"],"tags":[],"category":"Travel & Places","description":"canoe","unicode_version":"9.0"},{"emoji":"🚤","aliases":["speedboat"],"tags":["ship"],"category":"Travel & Places","description":"speedboat","unicode_version":"6.0"},{"emoji":"🛳️","aliases":["passenger_ship"],"tags":["cruise"],"category":"Travel & Places","description":"passenger ship","unicode_version":"7.0"},{"emoji":"⛴️","aliases":["ferry"],"tags":[],"category":"Travel & Places","description":"ferry","unicode_version":"5.2"},{"emoji":"🛥️","aliases":["motor_boat"],"tags":[],"category":"Travel & Places","description":"motor boat","unicode_version":"7.0"},{"emoji":"🚢","aliases":["ship"],"tags":[],"category":"Travel & Places","description":"ship","unicode_version":"6.0"},{"emoji":"✈️","aliases":["airplane"],"tags":["flight"],"category":"Travel & Places","description":"airplane","unicode_version":""},{"emoji":"🛩️","aliases":["small_airplane"],"tags":["flight"],"category":"Travel & Places","description":"small airplane","unicode_version":"7.0"},{"emoji":"🛫","aliases":["flight_departure"],"tags":[],"category":"Travel & Places","description":"airplane departure","unicode_version":"7.0"},{"emoji":"🛬","aliases":["flight_arrival"],"tags":[],"category":"Travel & Places","description":"airplane arrival","unicode_version":"7.0"},{"emoji":"🪂","aliases":["parachute"],"tags":[],"category":"Travel & Places","description":"parachute","unicode_version":"12.0"},{"emoji":"💺","aliases":["seat"],"tags":[],"category":"Travel & Places","description":"seat","unicode_version":"6.0"},{"emoji":"🚁","aliases":["helicopter"],"tags":[],"category":"Travel & Places","description":"helicopter","unicode_version":"6.0"},{"emoji":"🚟","aliases":["suspension_railway"],"tags":[],"category":"Travel & Places","description":"suspension railway","unicode_version":"6.0"},{"emoji":"🚠","aliases":["mountain_cableway"],"tags":[],"category":"Travel & Places","description":"mountain cableway","unicode_version":"6.0"},{"emoji":"🚡","aliases":["aerial_tramway"],"tags":[],"category":"Travel & Places","description":"aerial tramway","unicode_version":"6.0"},{"emoji":"🛰️","aliases":["artificial_satellite"],"tags":["orbit","space"],"category":"Travel & Places","description":"satellite","unicode_version":"7.0"},{"emoji":"🚀","aliases":["rocket"],"tags":["ship","launch"],"category":"Travel & Places","description":"rocket","unicode_version":"6.0"},{"emoji":"🛸","aliases":["flying_saucer"],"tags":["ufo"],"category":"Travel & Places","description":"flying saucer","unicode_version":"11.0"},{"emoji":"🛎️","aliases":["bellhop_bell"],"tags":[],"category":"Travel & Places","description":"bellhop bell","unicode_version":"7.0"},{"emoji":"🧳","aliases":["luggage"],"tags":[],"category":"Travel & Places","description":"luggage","unicode_version":"11.0"},{"emoji":"⌛","aliases":["hourglass"],"tags":["time"],"category":"Travel & Places","description":"hourglass done","unicode_version":""},{"emoji":"⏳","aliases":["hourglass_flowing_sand"],"tags":["time"],"category":"Travel & Places","description":"hourglass not done","unicode_version":"6.0"},{"emoji":"⌚","aliases":["watch"],"tags":["time"],"category":"Travel & Places","description":"watch","unicode_version":""},{"emoji":"⏰","aliases":["alarm_clock"],"tags":["morning"],"category":"Travel & Places","description":"alarm clock","unicode_version":"6.0"},{"emoji":"⏱️","aliases":["stopwatch"],"tags":[],"category":"Travel & Places","description":"stopwatch","unicode_version":"6.0"},{"emoji":"⏲️","aliases":["timer_clock"],"tags":[],"category":"Travel & Places","description":"timer clock","unicode_version":"6.0"},{"emoji":"🕰️","aliases":["mantelpiece_clock"],"tags":[],"category":"Travel & Places","description":"mantelpiece clock","unicode_version":"7.0"},{"emoji":"🕛","aliases":["clock12"],"tags":[],"category":"Travel & Places","description":"twelve o’clock","unicode_version":"6.0"},{"emoji":"🕧","aliases":["clock1230"],"tags":[],"category":"Travel & Places","description":"twelve-thirty","unicode_version":"6.0"},{"emoji":"🕐","aliases":["clock1"],"tags":[],"category":"Travel & Places","description":"one o’clock","unicode_version":"6.0"},{"emoji":"🕜","aliases":["clock130"],"tags":[],"category":"Travel & Places","description":"one-thirty","unicode_version":"6.0"},{"emoji":"🕑","aliases":["clock2"],"tags":[],"category":"Travel & Places","description":"two o’clock","unicode_version":"6.0"},{"emoji":"🕝","aliases":["clock230"],"tags":[],"category":"Travel & Places","description":"two-thirty","unicode_version":"6.0"},{"emoji":"🕒","aliases":["clock3"],"tags":[],"category":"Travel & Places","description":"three o’clock","unicode_version":"6.0"},{"emoji":"🕞","aliases":["clock330"],"tags":[],"category":"Travel & Places","description":"three-thirty","unicode_version":"6.0"},{"emoji":"🕓","aliases":["clock4"],"tags":[],"category":"Travel & Places","description":"four o’clock","unicode_version":"6.0"},{"emoji":"🕟","aliases":["clock430"],"tags":[],"category":"Travel & Places","description":"four-thirty","unicode_version":"6.0"},{"emoji":"🕔","aliases":["clock5"],"tags":[],"category":"Travel & Places","description":"five o’clock","unicode_version":"6.0"},{"emoji":"🕠","aliases":["clock530"],"tags":[],"category":"Travel & Places","description":"five-thirty","unicode_version":"6.0"},{"emoji":"🕕","aliases":["clock6"],"tags":[],"category":"Travel & Places","description":"six o’clock","unicode_version":"6.0"},{"emoji":"🕡","aliases":["clock630"],"tags":[],"category":"Travel & Places","description":"six-thirty","unicode_version":"6.0"},{"emoji":"🕖","aliases":["clock7"],"tags":[],"category":"Travel & Places","description":"seven o’clock","unicode_version":"6.0"},{"emoji":"🕢","aliases":["clock730"],"tags":[],"category":"Travel & Places","description":"seven-thirty","unicode_version":"6.0"},{"emoji":"🕗","aliases":["clock8"],"tags":[],"category":"Travel & Places","description":"eight o’clock","unicode_version":"6.0"},{"emoji":"🕣","aliases":["clock830"],"tags":[],"category":"Travel & Places","description":"eight-thirty","unicode_version":"6.0"},{"emoji":"🕘","aliases":["clock9"],"tags":[],"category":"Travel & Places","description":"nine o’clock","unicode_version":"6.0"},{"emoji":"🕤","aliases":["clock930"],"tags":[],"category":"Travel & Places","description":"nine-thirty","unicode_version":"6.0"},{"emoji":"🕙","aliases":["clock10"],"tags":[],"category":"Travel & Places","description":"ten o’clock","unicode_version":"6.0"},{"emoji":"🕥","aliases":["clock1030"],"tags":[],"category":"Travel & Places","description":"ten-thirty","unicode_version":"6.0"},{"emoji":"🕚","aliases":["clock11"],"tags":[],"category":"Travel & Places","description":"eleven o’clock","unicode_version":"6.0"},{"emoji":"🕦","aliases":["clock1130"],"tags":[],"category":"Travel & Places","description":"eleven-thirty","unicode_version":"6.0"},{"emoji":"🌑","aliases":["new_moon"],"tags":[],"category":"Travel & Places","description":"new moon","unicode_version":"6.0"},{"emoji":"🌒","aliases":["waxing_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waxing crescent moon","unicode_version":"6.0"},{"emoji":"🌓","aliases":["first_quarter_moon"],"tags":[],"category":"Travel & Places","description":"first quarter moon","unicode_version":"6.0"},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waxing gibbous moon","unicode_version":"6.0"},{"emoji":"🌕","aliases":["full_moon"],"tags":[],"category":"Travel & Places","description":"full moon","unicode_version":"6.0"},{"emoji":"🌖","aliases":["waning_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waning gibbous moon","unicode_version":"6.0"},{"emoji":"🌗","aliases":["last_quarter_moon"],"tags":[],"category":"Travel & Places","description":"last quarter moon","unicode_version":"6.0"},{"emoji":"🌘","aliases":["waning_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waning crescent moon","unicode_version":"6.0"},{"emoji":"🌙","aliases":["crescent_moon"],"tags":["night"],"category":"Travel & Places","description":"crescent moon","unicode_version":"6.0"},{"emoji":"🌚","aliases":["new_moon_with_face"],"tags":[],"category":"Travel & Places","description":"new moon face","unicode_version":"6.0"},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"first quarter moon face","unicode_version":"6.0"},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"last quarter moon face","unicode_version":"6.0"},{"emoji":"🌡️","aliases":["thermometer"],"tags":[],"category":"Travel & Places","description":"thermometer","unicode_version":"7.0"},{"emoji":"☀️","aliases":["sunny"],"tags":["weather"],"category":"Travel & Places","description":"sun","unicode_version":""},{"emoji":"🌝","aliases":["full_moon_with_face"],"tags":[],"category":"Travel & Places","description":"full moon face","unicode_version":"6.0"},{"emoji":"🌞","aliases":["sun_with_face"],"tags":["summer"],"category":"Travel & Places","description":"sun with face","unicode_version":"6.0"},{"emoji":"🪐","aliases":["ringed_planet"],"tags":[],"category":"Travel & Places","description":"ringed planet","unicode_version":"12.0"},{"emoji":"⭐","aliases":["star"],"tags":[],"category":"Travel & Places","description":"star","unicode_version":"5.1"},{"emoji":"🌟","aliases":["star2"],"tags":[],"category":"Travel & Places","description":"glowing star","unicode_version":"6.0"},{"emoji":"🌠","aliases":["stars"],"tags":[],"category":"Travel & Places","description":"shooting star","unicode_version":"6.0"},{"emoji":"🌌","aliases":["milky_way"],"tags":[],"category":"Travel & Places","description":"milky way","unicode_version":"6.0"},{"emoji":"☁️","aliases":["cloud"],"tags":[],"category":"Travel & Places","description":"cloud","unicode_version":""},{"emoji":"⛅","aliases":["partly_sunny"],"tags":["weather","cloud"],"category":"Travel & Places","description":"sun behind cloud","unicode_version":"5.2"},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"],"tags":[],"category":"Travel & Places","description":"cloud with lightning and rain","unicode_version":"5.2"},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind small cloud","unicode_version":"7.0"},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind large cloud","unicode_version":"7.0"},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind rain cloud","unicode_version":"7.0"},{"emoji":"🌧️","aliases":["cloud_with_rain"],"tags":[],"category":"Travel & Places","description":"cloud with rain","unicode_version":"7.0"},{"emoji":"🌨️","aliases":["cloud_with_snow"],"tags":[],"category":"Travel & Places","description":"cloud with snow","unicode_version":"7.0"},{"emoji":"🌩️","aliases":["cloud_with_lightning"],"tags":[],"category":"Travel & Places","description":"cloud with lightning","unicode_version":"7.0"},{"emoji":"🌪️","aliases":["tornado"],"tags":[],"category":"Travel & Places","description":"tornado","unicode_version":"7.0"},{"emoji":"🌫️","aliases":["fog"],"tags":[],"category":"Travel & Places","description":"fog","unicode_version":"7.0"},{"emoji":"🌬️","aliases":["wind_face"],"tags":[],"category":"Travel & Places","description":"wind face","unicode_version":"7.0"},{"emoji":"🌀","aliases":["cyclone"],"tags":["swirl"],"category":"Travel & Places","description":"cyclone","unicode_version":"6.0"},{"emoji":"🌈","aliases":["rainbow"],"tags":[],"category":"Travel & Places","description":"rainbow","unicode_version":"6.0"},{"emoji":"🌂","aliases":["closed_umbrella"],"tags":["weather","rain"],"category":"Travel & Places","description":"closed umbrella","unicode_version":"6.0"},{"emoji":"☂️","aliases":["open_umbrella"],"tags":[],"category":"Travel & Places","description":"umbrella","unicode_version":""},{"emoji":"☔","aliases":["umbrella"],"tags":["rain","weather"],"category":"Travel & Places","description":"umbrella with rain drops","unicode_version":"4.0"},{"emoji":"⛱️","aliases":["parasol_on_ground"],"tags":["beach_umbrella"],"category":"Travel & Places","description":"umbrella on ground","unicode_version":"5.2"},{"emoji":"⚡","aliases":["zap"],"tags":["lightning","thunder"],"category":"Travel & Places","description":"high voltage","unicode_version":"4.0"},{"emoji":"❄️","aliases":["snowflake"],"tags":["winter","cold","weather"],"category":"Travel & Places","description":"snowflake","unicode_version":""},{"emoji":"☃️","aliases":["snowman_with_snow"],"tags":["winter","christmas"],"category":"Travel & Places","description":"snowman","unicode_version":""},{"emoji":"⛄","aliases":["snowman"],"tags":["winter"],"category":"Travel & Places","description":"snowman without snow","unicode_version":"5.2"},{"emoji":"☄️","aliases":["comet"],"tags":[],"category":"Travel & Places","description":"comet","unicode_version":""},{"emoji":"🔥","aliases":["fire"],"tags":["burn"],"category":"Travel & Places","description":"fire","unicode_version":"6.0"},{"emoji":"💧","aliases":["droplet"],"tags":["water"],"category":"Travel & Places","description":"droplet","unicode_version":"6.0"},{"emoji":"🌊","aliases":["ocean"],"tags":["sea"],"category":"Travel & Places","description":"water wave","unicode_version":"6.0"},{"emoji":"🎃","aliases":["jack_o_lantern"],"tags":["halloween"],"category":"Activities","description":"jack-o-lantern","unicode_version":"6.0"},{"emoji":"🎄","aliases":["christmas_tree"],"tags":[],"category":"Activities","description":"Christmas tree","unicode_version":"6.0"},{"emoji":"🎆","aliases":["fireworks"],"tags":["festival","celebration"],"category":"Activities","description":"fireworks","unicode_version":"6.0"},{"emoji":"🎇","aliases":["sparkler"],"tags":[],"category":"Activities","description":"sparkler","unicode_version":"6.0"},{"emoji":"🧨","aliases":["firecracker"],"tags":[],"category":"Activities","description":"firecracker","unicode_version":"11.0"},{"emoji":"✨","aliases":["sparkles"],"tags":["shiny"],"category":"Activities","description":"sparkles","unicode_version":"6.0"},{"emoji":"🎈","aliases":["balloon"],"tags":["party","birthday"],"category":"Activities","description":"balloon","unicode_version":"6.0"},{"emoji":"🎉","aliases":["tada"],"tags":["hooray","party"],"category":"Activities","description":"party popper","unicode_version":"6.0"},{"emoji":"🎊","aliases":["confetti_ball"],"tags":[],"category":"Activities","description":"confetti ball","unicode_version":"6.0"},{"emoji":"🎋","aliases":["tanabata_tree"],"tags":[],"category":"Activities","description":"tanabata tree","unicode_version":"6.0"},{"emoji":"🎍","aliases":["bamboo"],"tags":[],"category":"Activities","description":"pine decoration","unicode_version":"6.0"},{"emoji":"🎎","aliases":["dolls"],"tags":[],"category":"Activities","description":"Japanese dolls","unicode_version":"6.0"},{"emoji":"🎏","aliases":["flags"],"tags":[],"category":"Activities","description":"carp streamer","unicode_version":"6.0"},{"emoji":"🎐","aliases":["wind_chime"],"tags":[],"category":"Activities","description":"wind chime","unicode_version":"6.0"},{"emoji":"🎑","aliases":["rice_scene"],"tags":[],"category":"Activities","description":"moon viewing ceremony","unicode_version":"6.0"},{"emoji":"🧧","aliases":["red_envelope"],"tags":[],"category":"Activities","description":"red envelope","unicode_version":"11.0"},{"emoji":"🎀","aliases":["ribbon"],"tags":[],"category":"Activities","description":"ribbon","unicode_version":"6.0"},{"emoji":"🎁","aliases":["gift"],"tags":["present","birthday","christmas"],"category":"Activities","description":"wrapped gift","unicode_version":"6.0"},{"emoji":"🎗️","aliases":["reminder_ribbon"],"tags":[],"category":"Activities","description":"reminder ribbon","unicode_version":"7.0"},{"emoji":"🎟️","aliases":["tickets"],"tags":[],"category":"Activities","description":"admission tickets","unicode_version":"7.0"},{"emoji":"🎫","aliases":["ticket"],"tags":[],"category":"Activities","description":"ticket","unicode_version":"6.0"},{"emoji":"🎖️","aliases":["medal_military"],"tags":[],"category":"Activities","description":"military medal","unicode_version":"7.0"},{"emoji":"🏆","aliases":["trophy"],"tags":["award","contest","winner"],"category":"Activities","description":"trophy","unicode_version":"6.0"},{"emoji":"🏅","aliases":["medal_sports"],"tags":["gold","winner"],"category":"Activities","description":"sports medal","unicode_version":"7.0"},{"emoji":"🥇","aliases":["1st_place_medal"],"tags":["gold"],"category":"Activities","description":"1st place medal","unicode_version":"9.0"},{"emoji":"🥈","aliases":["2nd_place_medal"],"tags":["silver"],"category":"Activities","description":"2nd place medal","unicode_version":"9.0"},{"emoji":"🥉","aliases":["3rd_place_medal"],"tags":["bronze"],"category":"Activities","description":"3rd place medal","unicode_version":"9.0"},{"emoji":"⚽","aliases":["soccer"],"tags":["sports"],"category":"Activities","description":"soccer ball","unicode_version":"5.2"},{"emoji":"⚾","aliases":["baseball"],"tags":["sports"],"category":"Activities","description":"baseball","unicode_version":"5.2"},{"emoji":"🥎","aliases":["softball"],"tags":[],"category":"Activities","description":"softball","unicode_version":"11.0"},{"emoji":"🏀","aliases":["basketball"],"tags":["sports"],"category":"Activities","description":"basketball","unicode_version":"6.0"},{"emoji":"🏐","aliases":["volleyball"],"tags":[],"category":"Activities","description":"volleyball","unicode_version":"8.0"},{"emoji":"🏈","aliases":["football"],"tags":["sports"],"category":"Activities","description":"american football","unicode_version":"6.0"},{"emoji":"🏉","aliases":["rugby_football"],"tags":[],"category":"Activities","description":"rugby football","unicode_version":"6.0"},{"emoji":"🎾","aliases":["tennis"],"tags":["sports"],"category":"Activities","description":"tennis","unicode_version":"6.0"},{"emoji":"🥏","aliases":["flying_disc"],"tags":[],"category":"Activities","description":"flying disc","unicode_version":"11.0"},{"emoji":"🎳","aliases":["bowling"],"tags":[],"category":"Activities","description":"bowling","unicode_version":"6.0"},{"emoji":"🏏","aliases":["cricket_game"],"tags":[],"category":"Activities","description":"cricket game","unicode_version":"8.0"},{"emoji":"🏑","aliases":["field_hockey"],"tags":[],"category":"Activities","description":"field hockey","unicode_version":"8.0"},{"emoji":"🏒","aliases":["ice_hockey"],"tags":[],"category":"Activities","description":"ice hockey","unicode_version":"8.0"},{"emoji":"🥍","aliases":["lacrosse"],"tags":[],"category":"Activities","description":"lacrosse","unicode_version":"11.0"},{"emoji":"🏓","aliases":["ping_pong"],"tags":[],"category":"Activities","description":"ping pong","unicode_version":"8.0"},{"emoji":"🏸","aliases":["badminton"],"tags":[],"category":"Activities","description":"badminton","unicode_version":"8.0"},{"emoji":"🥊","aliases":["boxing_glove"],"tags":[],"category":"Activities","description":"boxing glove","unicode_version":"9.0"},{"emoji":"🥋","aliases":["martial_arts_uniform"],"tags":[],"category":"Activities","description":"martial arts uniform","unicode_version":"9.0"},{"emoji":"🥅","aliases":["goal_net"],"tags":[],"category":"Activities","description":"goal net","unicode_version":"9.0"},{"emoji":"⛳","aliases":["golf"],"tags":[],"category":"Activities","description":"flag in hole","unicode_version":"5.2"},{"emoji":"⛸️","aliases":["ice_skate"],"tags":["skating"],"category":"Activities","description":"ice skate","unicode_version":"5.2"},{"emoji":"🎣","aliases":["fishing_pole_and_fish"],"tags":[],"category":"Activities","description":"fishing pole","unicode_version":"6.0"},{"emoji":"🤿","aliases":["diving_mask"],"tags":[],"category":"Activities","description":"diving mask","unicode_version":"12.0"},{"emoji":"🎽","aliases":["running_shirt_with_sash"],"tags":["marathon"],"category":"Activities","description":"running shirt","unicode_version":"6.0"},{"emoji":"🎿","aliases":["ski"],"tags":[],"category":"Activities","description":"skis","unicode_version":"6.0"},{"emoji":"🛷","aliases":["sled"],"tags":[],"category":"Activities","description":"sled","unicode_version":"11.0"},{"emoji":"🥌","aliases":["curling_stone"],"tags":[],"category":"Activities","description":"curling stone","unicode_version":"11.0"},{"emoji":"🎯","aliases":["dart"],"tags":["target"],"category":"Activities","description":"bullseye","unicode_version":"6.0"},{"emoji":"🪀","aliases":["yo_yo"],"tags":[],"category":"Activities","description":"yo-yo","unicode_version":"12.0"},{"emoji":"🪁","aliases":["kite"],"tags":[],"category":"Activities","description":"kite","unicode_version":"12.0"},{"emoji":"🎱","aliases":["8ball"],"tags":["pool","billiards"],"category":"Activities","description":"pool 8 ball","unicode_version":"6.0"},{"emoji":"🔮","aliases":["crystal_ball"],"tags":["fortune"],"category":"Activities","description":"crystal ball","unicode_version":"6.0"},{"emoji":"🪄","aliases":["magic_wand"],"tags":[],"category":"Activities","description":"magic wand","unicode_version":"13.0"},{"emoji":"🧿","aliases":["nazar_amulet"],"tags":[],"category":"Activities","description":"nazar amulet","unicode_version":"11.0"},{"emoji":"🎮","aliases":["video_game"],"tags":["play","controller","console"],"category":"Activities","description":"video game","unicode_version":"6.0"},{"emoji":"🕹️","aliases":["joystick"],"tags":[],"category":"Activities","description":"joystick","unicode_version":"7.0"},{"emoji":"🎰","aliases":["slot_machine"],"tags":[],"category":"Activities","description":"slot machine","unicode_version":"6.0"},{"emoji":"🎲","aliases":["game_die"],"tags":["dice","gambling"],"category":"Activities","description":"game die","unicode_version":"6.0"},{"emoji":"🧩","aliases":["jigsaw"],"tags":[],"category":"Activities","description":"puzzle piece","unicode_version":"11.0"},{"emoji":"🧸","aliases":["teddy_bear"],"tags":[],"category":"Activities","description":"teddy bear","unicode_version":"11.0"},{"emoji":"🪅","aliases":["pinata"],"tags":[],"category":"Activities","description":"piñata","unicode_version":"13.0"},{"emoji":"🪆","aliases":["nesting_dolls"],"tags":[],"category":"Activities","description":"nesting dolls","unicode_version":"13.0"},{"emoji":"♠️","aliases":["spades"],"tags":[],"category":"Activities","description":"spade suit","unicode_version":""},{"emoji":"♥️","aliases":["hearts"],"tags":[],"category":"Activities","description":"heart suit","unicode_version":""},{"emoji":"♦️","aliases":["diamonds"],"tags":[],"category":"Activities","description":"diamond suit","unicode_version":""},{"emoji":"♣️","aliases":["clubs"],"tags":[],"category":"Activities","description":"club suit","unicode_version":""},{"emoji":"♟️","aliases":["chess_pawn"],"tags":[],"category":"Activities","description":"chess pawn","unicode_version":"11.0"},{"emoji":"🃏","aliases":["black_joker"],"tags":[],"category":"Activities","description":"joker","unicode_version":"6.0"},{"emoji":"🀄","aliases":["mahjong"],"tags":[],"category":"Activities","description":"mahjong red dragon","unicode_version":""},{"emoji":"🎴","aliases":["flower_playing_cards"],"tags":[],"category":"Activities","description":"flower playing cards","unicode_version":"6.0"},{"emoji":"🎭","aliases":["performing_arts"],"tags":["theater","drama"],"category":"Activities","description":"performing arts","unicode_version":"6.0"},{"emoji":"🖼️","aliases":["framed_picture"],"tags":[],"category":"Activities","description":"framed picture","unicode_version":"7.0"},{"emoji":"🎨","aliases":["art"],"tags":["design","paint"],"category":"Activities","description":"artist palette","unicode_version":"6.0"},{"emoji":"🧵","aliases":["thread"],"tags":[],"category":"Activities","description":"thread","unicode_version":"11.0"},{"emoji":"🪡","aliases":["sewing_needle"],"tags":[],"category":"Activities","description":"sewing needle","unicode_version":"13.0"},{"emoji":"🧶","aliases":["yarn"],"tags":[],"category":"Activities","description":"yarn","unicode_version":"11.0"},{"emoji":"🪢","aliases":["knot"],"tags":[],"category":"Activities","description":"knot","unicode_version":"13.0"},{"emoji":"👓","aliases":["eyeglasses"],"tags":["glasses"],"category":"Objects","description":"glasses","unicode_version":"6.0"},{"emoji":"🕶️","aliases":["dark_sunglasses"],"tags":[],"category":"Objects","description":"sunglasses","unicode_version":"7.0"},{"emoji":"🥽","aliases":["goggles"],"tags":[],"category":"Objects","description":"goggles","unicode_version":"11.0"},{"emoji":"🥼","aliases":["lab_coat"],"tags":[],"category":"Objects","description":"lab coat","unicode_version":"11.0"},{"emoji":"🦺","aliases":["safety_vest"],"tags":[],"category":"Objects","description":"safety vest","unicode_version":"12.0"},{"emoji":"👔","aliases":["necktie"],"tags":["shirt","formal"],"category":"Objects","description":"necktie","unicode_version":"6.0"},{"emoji":"👕","aliases":["shirt","tshirt"],"tags":[],"category":"Objects","description":"t-shirt","unicode_version":"6.0"},{"emoji":"👖","aliases":["jeans"],"tags":["pants"],"category":"Objects","description":"jeans","unicode_version":"6.0"},{"emoji":"🧣","aliases":["scarf"],"tags":[],"category":"Objects","description":"scarf","unicode_version":"11.0"},{"emoji":"🧤","aliases":["gloves"],"tags":[],"category":"Objects","description":"gloves","unicode_version":"11.0"},{"emoji":"🧥","aliases":["coat"],"tags":[],"category":"Objects","description":"coat","unicode_version":"11.0"},{"emoji":"🧦","aliases":["socks"],"tags":[],"category":"Objects","description":"socks","unicode_version":"11.0"},{"emoji":"👗","aliases":["dress"],"tags":[],"category":"Objects","description":"dress","unicode_version":"6.0"},{"emoji":"👘","aliases":["kimono"],"tags":[],"category":"Objects","description":"kimono","unicode_version":"6.0"},{"emoji":"🥻","aliases":["sari"],"tags":[],"category":"Objects","description":"sari","unicode_version":"12.0"},{"emoji":"🩱","aliases":["one_piece_swimsuit"],"tags":[],"category":"Objects","description":"one-piece swimsuit","unicode_version":"12.0"},{"emoji":"🩲","aliases":["swim_brief"],"tags":[],"category":"Objects","description":"briefs","unicode_version":"12.0"},{"emoji":"🩳","aliases":["shorts"],"tags":[],"category":"Objects","description":"shorts","unicode_version":"12.0"},{"emoji":"👙","aliases":["bikini"],"tags":["beach"],"category":"Objects","description":"bikini","unicode_version":"6.0"},{"emoji":"👚","aliases":["womans_clothes"],"tags":[],"category":"Objects","description":"woman’s clothes","unicode_version":"6.0"},{"emoji":"👛","aliases":["purse"],"tags":[],"category":"Objects","description":"purse","unicode_version":"6.0"},{"emoji":"👜","aliases":["handbag"],"tags":["bag"],"category":"Objects","description":"handbag","unicode_version":"6.0"},{"emoji":"👝","aliases":["pouch"],"tags":["bag"],"category":"Objects","description":"clutch bag","unicode_version":"6.0"},{"emoji":"🛍️","aliases":["shopping"],"tags":["bags"],"category":"Objects","description":"shopping bags","unicode_version":"7.0"},{"emoji":"🎒","aliases":["school_satchel"],"tags":[],"category":"Objects","description":"backpack","unicode_version":"6.0"},{"emoji":"🩴","aliases":["thong_sandal"],"tags":[],"category":"Objects","description":"thong sandal","unicode_version":"13.0"},{"emoji":"👞","aliases":["mans_shoe","shoe"],"tags":[],"category":"Objects","description":"man’s shoe","unicode_version":"6.0"},{"emoji":"👟","aliases":["athletic_shoe"],"tags":["sneaker","sport","running"],"category":"Objects","description":"running shoe","unicode_version":"6.0"},{"emoji":"🥾","aliases":["hiking_boot"],"tags":[],"category":"Objects","description":"hiking boot","unicode_version":"11.0"},{"emoji":"🥿","aliases":["flat_shoe"],"tags":[],"category":"Objects","description":"flat shoe","unicode_version":"11.0"},{"emoji":"👠","aliases":["high_heel"],"tags":["shoe"],"category":"Objects","description":"high-heeled shoe","unicode_version":"6.0"},{"emoji":"👡","aliases":["sandal"],"tags":["shoe"],"category":"Objects","description":"woman’s sandal","unicode_version":"6.0"},{"emoji":"🩰","aliases":["ballet_shoes"],"tags":[],"category":"Objects","description":"ballet shoes","unicode_version":"12.0"},{"emoji":"👢","aliases":["boot"],"tags":[],"category":"Objects","description":"woman’s boot","unicode_version":"6.0"},{"emoji":"👑","aliases":["crown"],"tags":["king","queen","royal"],"category":"Objects","description":"crown","unicode_version":"6.0"},{"emoji":"👒","aliases":["womans_hat"],"tags":[],"category":"Objects","description":"woman’s hat","unicode_version":"6.0"},{"emoji":"🎩","aliases":["tophat"],"tags":["hat","classy"],"category":"Objects","description":"top hat","unicode_version":"6.0"},{"emoji":"🎓","aliases":["mortar_board"],"tags":["education","college","university","graduation"],"category":"Objects","description":"graduation cap","unicode_version":"6.0"},{"emoji":"🧢","aliases":["billed_cap"],"tags":[],"category":"Objects","description":"billed cap","unicode_version":"11.0"},{"emoji":"🪖","aliases":["military_helmet"],"tags":[],"category":"Objects","description":"military helmet","unicode_version":"13.0"},{"emoji":"⛑️","aliases":["rescue_worker_helmet"],"tags":[],"category":"Objects","description":"rescue worker’s helmet","unicode_version":"5.2"},{"emoji":"📿","aliases":["prayer_beads"],"tags":[],"category":"Objects","description":"prayer beads","unicode_version":"8.0"},{"emoji":"💄","aliases":["lipstick"],"tags":["makeup"],"category":"Objects","description":"lipstick","unicode_version":"6.0"},{"emoji":"💍","aliases":["ring"],"tags":["wedding","marriage","engaged"],"category":"Objects","description":"ring","unicode_version":"6.0"},{"emoji":"💎","aliases":["gem"],"tags":["diamond"],"category":"Objects","description":"gem stone","unicode_version":"6.0"},{"emoji":"🔇","aliases":["mute"],"tags":["sound","volume"],"category":"Objects","description":"muted speaker","unicode_version":"6.0"},{"emoji":"🔈","aliases":["speaker"],"tags":[],"category":"Objects","description":"speaker low volume","unicode_version":"6.0"},{"emoji":"🔉","aliases":["sound"],"tags":["volume"],"category":"Objects","description":"speaker medium volume","unicode_version":"6.0"},{"emoji":"🔊","aliases":["loud_sound"],"tags":["volume"],"category":"Objects","description":"speaker high volume","unicode_version":"6.0"},{"emoji":"📢","aliases":["loudspeaker"],"tags":["announcement"],"category":"Objects","description":"loudspeaker","unicode_version":"6.0"},{"emoji":"📣","aliases":["mega"],"tags":[],"category":"Objects","description":"megaphone","unicode_version":"6.0"},{"emoji":"📯","aliases":["postal_horn"],"tags":[],"category":"Objects","description":"postal horn","unicode_version":"6.0"},{"emoji":"🔔","aliases":["bell"],"tags":["sound","notification"],"category":"Objects","description":"bell","unicode_version":"6.0"},{"emoji":"🔕","aliases":["no_bell"],"tags":["volume","off"],"category":"Objects","description":"bell with slash","unicode_version":"6.0"},{"emoji":"🎼","aliases":["musical_score"],"tags":[],"category":"Objects","description":"musical score","unicode_version":"6.0"},{"emoji":"🎵","aliases":["musical_note"],"tags":[],"category":"Objects","description":"musical note","unicode_version":"6.0"},{"emoji":"🎶","aliases":["notes"],"tags":["music"],"category":"Objects","description":"musical notes","unicode_version":"6.0"},{"emoji":"🎙️","aliases":["studio_microphone"],"tags":["podcast"],"category":"Objects","description":"studio microphone","unicode_version":"7.0"},{"emoji":"🎚️","aliases":["level_slider"],"tags":[],"category":"Objects","description":"level slider","unicode_version":"7.0"},{"emoji":"🎛️","aliases":["control_knobs"],"tags":[],"category":"Objects","description":"control knobs","unicode_version":"7.0"},{"emoji":"🎤","aliases":["microphone"],"tags":["sing"],"category":"Objects","description":"microphone","unicode_version":"6.0"},{"emoji":"🎧","aliases":["headphones"],"tags":["music","earphones"],"category":"Objects","description":"headphone","unicode_version":"6.0"},{"emoji":"📻","aliases":["radio"],"tags":["podcast"],"category":"Objects","description":"radio","unicode_version":"6.0"},{"emoji":"🎷","aliases":["saxophone"],"tags":[],"category":"Objects","description":"saxophone","unicode_version":"6.0"},{"emoji":"🪗","aliases":["accordion"],"tags":[],"category":"Objects","description":"accordion","unicode_version":"13.0"},{"emoji":"🎸","aliases":["guitar"],"tags":["rock"],"category":"Objects","description":"guitar","unicode_version":"6.0"},{"emoji":"🎹","aliases":["musical_keyboard"],"tags":["piano"],"category":"Objects","description":"musical keyboard","unicode_version":"6.0"},{"emoji":"🎺","aliases":["trumpet"],"tags":[],"category":"Objects","description":"trumpet","unicode_version":"6.0"},{"emoji":"🎻","aliases":["violin"],"tags":[],"category":"Objects","description":"violin","unicode_version":"6.0"},{"emoji":"🪕","aliases":["banjo"],"tags":[],"category":"Objects","description":"banjo","unicode_version":"12.0"},{"emoji":"🥁","aliases":["drum"],"tags":[],"category":"Objects","description":"drum","unicode_version":""},{"emoji":"🪘","aliases":["long_drum"],"tags":[],"category":"Objects","description":"long drum","unicode_version":"13.0"},{"emoji":"📱","aliases":["iphone"],"tags":["smartphone","mobile"],"category":"Objects","description":"mobile phone","unicode_version":"6.0"},{"emoji":"📲","aliases":["calling"],"tags":["call","incoming"],"category":"Objects","description":"mobile phone with arrow","unicode_version":"6.0"},{"emoji":"☎️","aliases":["phone","telephone"],"tags":[],"category":"Objects","description":"telephone","unicode_version":""},{"emoji":"📞","aliases":["telephone_receiver"],"tags":["phone","call"],"category":"Objects","description":"telephone receiver","unicode_version":"6.0"},{"emoji":"📟","aliases":["pager"],"tags":[],"category":"Objects","description":"pager","unicode_version":"6.0"},{"emoji":"📠","aliases":["fax"],"tags":[],"category":"Objects","description":"fax machine","unicode_version":"6.0"},{"emoji":"🔋","aliases":["battery"],"tags":["power"],"category":"Objects","description":"battery","unicode_version":"6.0"},{"emoji":"🔌","aliases":["electric_plug"],"tags":[],"category":"Objects","description":"electric plug","unicode_version":"6.0"},{"emoji":"💻","aliases":["computer"],"tags":["desktop","screen"],"category":"Objects","description":"laptop","unicode_version":"6.0"},{"emoji":"🖥️","aliases":["desktop_computer"],"tags":[],"category":"Objects","description":"desktop computer","unicode_version":"7.0"},{"emoji":"🖨️","aliases":["printer"],"tags":[],"category":"Objects","description":"printer","unicode_version":"7.0"},{"emoji":"⌨️","aliases":["keyboard"],"tags":[],"category":"Objects","description":"keyboard","unicode_version":""},{"emoji":"🖱️","aliases":["computer_mouse"],"tags":[],"category":"Objects","description":"computer mouse","unicode_version":"7.0"},{"emoji":"🖲️","aliases":["trackball"],"tags":[],"category":"Objects","description":"trackball","unicode_version":"7.0"},{"emoji":"💽","aliases":["minidisc"],"tags":[],"category":"Objects","description":"computer disk","unicode_version":"6.0"},{"emoji":"💾","aliases":["floppy_disk"],"tags":["save"],"category":"Objects","description":"floppy disk","unicode_version":"6.0"},{"emoji":"💿","aliases":["cd"],"tags":[],"category":"Objects","description":"optical disk","unicode_version":"6.0"},{"emoji":"📀","aliases":["dvd"],"tags":[],"category":"Objects","description":"dvd","unicode_version":"6.0"},{"emoji":"🧮","aliases":["abacus"],"tags":[],"category":"Objects","description":"abacus","unicode_version":"11.0"},{"emoji":"🎥","aliases":["movie_camera"],"tags":["film","video"],"category":"Objects","description":"movie camera","unicode_version":"6.0"},{"emoji":"🎞️","aliases":["film_strip"],"tags":[],"category":"Objects","description":"film frames","unicode_version":"7.0"},{"emoji":"📽️","aliases":["film_projector"],"tags":[],"category":"Objects","description":"film projector","unicode_version":"7.0"},{"emoji":"🎬","aliases":["clapper"],"tags":["film"],"category":"Objects","description":"clapper board","unicode_version":"6.0"},{"emoji":"📺","aliases":["tv"],"tags":[],"category":"Objects","description":"television","unicode_version":"6.0"},{"emoji":"📷","aliases":["camera"],"tags":["photo"],"category":"Objects","description":"camera","unicode_version":"6.0"},{"emoji":"📸","aliases":["camera_flash"],"tags":["photo"],"category":"Objects","description":"camera with flash","unicode_version":"7.0"},{"emoji":"📹","aliases":["video_camera"],"tags":[],"category":"Objects","description":"video camera","unicode_version":"6.0"},{"emoji":"📼","aliases":["vhs"],"tags":[],"category":"Objects","description":"videocassette","unicode_version":"6.0"},{"emoji":"🔍","aliases":["mag"],"tags":["search","zoom"],"category":"Objects","description":"magnifying glass tilted left","unicode_version":"6.0"},{"emoji":"🔎","aliases":["mag_right"],"tags":[],"category":"Objects","description":"magnifying glass tilted right","unicode_version":"6.0"},{"emoji":"🕯️","aliases":["candle"],"tags":[],"category":"Objects","description":"candle","unicode_version":"7.0"},{"emoji":"💡","aliases":["bulb"],"tags":["idea","light"],"category":"Objects","description":"light bulb","unicode_version":"6.0"},{"emoji":"🔦","aliases":["flashlight"],"tags":[],"category":"Objects","description":"flashlight","unicode_version":"6.0"},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"],"tags":[],"category":"Objects","description":"red paper lantern","unicode_version":"6.0"},{"emoji":"🪔","aliases":["diya_lamp"],"tags":[],"category":"Objects","description":"diya lamp","unicode_version":"12.0"},{"emoji":"📔","aliases":["notebook_with_decorative_cover"],"tags":[],"category":"Objects","description":"notebook with decorative cover","unicode_version":"6.0"},{"emoji":"📕","aliases":["closed_book"],"tags":[],"category":"Objects","description":"closed book","unicode_version":"6.0"},{"emoji":"📖","aliases":["book","open_book"],"tags":[],"category":"Objects","description":"open book","unicode_version":"6.0"},{"emoji":"📗","aliases":["green_book"],"tags":[],"category":"Objects","description":"green book","unicode_version":"6.0"},{"emoji":"📘","aliases":["blue_book"],"tags":[],"category":"Objects","description":"blue book","unicode_version":"6.0"},{"emoji":"📙","aliases":["orange_book"],"tags":[],"category":"Objects","description":"orange book","unicode_version":"6.0"},{"emoji":"📚","aliases":["books"],"tags":["library"],"category":"Objects","description":"books","unicode_version":"6.0"},{"emoji":"📓","aliases":["notebook"],"tags":[],"category":"Objects","description":"notebook","unicode_version":"6.0"},{"emoji":"📒","aliases":["ledger"],"tags":[],"category":"Objects","description":"ledger","unicode_version":"6.0"},{"emoji":"📃","aliases":["page_with_curl"],"tags":[],"category":"Objects","description":"page with curl","unicode_version":"6.0"},{"emoji":"📜","aliases":["scroll"],"tags":["document"],"category":"Objects","description":"scroll","unicode_version":"6.0"},{"emoji":"📄","aliases":["page_facing_up"],"tags":["document"],"category":"Objects","description":"page facing up","unicode_version":"6.0"},{"emoji":"📰","aliases":["newspaper"],"tags":["press"],"category":"Objects","description":"newspaper","unicode_version":"6.0"},{"emoji":"🗞️","aliases":["newspaper_roll"],"tags":["press"],"category":"Objects","description":"rolled-up newspaper","unicode_version":"7.0"},{"emoji":"📑","aliases":["bookmark_tabs"],"tags":[],"category":"Objects","description":"bookmark tabs","unicode_version":"6.0"},{"emoji":"🔖","aliases":["bookmark"],"tags":[],"category":"Objects","description":"bookmark","unicode_version":"6.0"},{"emoji":"🏷️","aliases":["label"],"tags":["tag"],"category":"Objects","description":"label","unicode_version":"7.0"},{"emoji":"💰","aliases":["moneybag"],"tags":["dollar","cream"],"category":"Objects","description":"money bag","unicode_version":"6.0"},{"emoji":"🪙","aliases":["coin"],"tags":[],"category":"Objects","description":"coin","unicode_version":"13.0"},{"emoji":"💴","aliases":["yen"],"tags":[],"category":"Objects","description":"yen banknote","unicode_version":"6.0"},{"emoji":"💵","aliases":["dollar"],"tags":["money"],"category":"Objects","description":"dollar banknote","unicode_version":"6.0"},{"emoji":"💶","aliases":["euro"],"tags":[],"category":"Objects","description":"euro banknote","unicode_version":"6.0"},{"emoji":"💷","aliases":["pound"],"tags":[],"category":"Objects","description":"pound banknote","unicode_version":"6.0"},{"emoji":"💸","aliases":["money_with_wings"],"tags":["dollar"],"category":"Objects","description":"money with wings","unicode_version":"6.0"},{"emoji":"💳","aliases":["credit_card"],"tags":["subscription"],"category":"Objects","description":"credit card","unicode_version":"6.0"},{"emoji":"🧾","aliases":["receipt"],"tags":[],"category":"Objects","description":"receipt","unicode_version":"11.0"},{"emoji":"💹","aliases":["chart"],"tags":[],"category":"Objects","description":"chart increasing with yen","unicode_version":"6.0"},{"emoji":"✉️","aliases":["envelope"],"tags":["letter","email"],"category":"Objects","description":"envelope","unicode_version":""},{"emoji":"📧","aliases":["email","e-mail"],"tags":[],"category":"Objects","description":"e-mail","unicode_version":"6.0"},{"emoji":"📨","aliases":["incoming_envelope"],"tags":[],"category":"Objects","description":"incoming envelope","unicode_version":"6.0"},{"emoji":"📩","aliases":["envelope_with_arrow"],"tags":[],"category":"Objects","description":"envelope with arrow","unicode_version":"6.0"},{"emoji":"📤","aliases":["outbox_tray"],"tags":[],"category":"Objects","description":"outbox tray","unicode_version":"6.0"},{"emoji":"📥","aliases":["inbox_tray"],"tags":[],"category":"Objects","description":"inbox tray","unicode_version":"6.0"},{"emoji":"📦","aliases":["package"],"tags":["shipping"],"category":"Objects","description":"package","unicode_version":"6.0"},{"emoji":"📫","aliases":["mailbox"],"tags":[],"category":"Objects","description":"closed mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📪","aliases":["mailbox_closed"],"tags":[],"category":"Objects","description":"closed mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📬","aliases":["mailbox_with_mail"],"tags":[],"category":"Objects","description":"open mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📭","aliases":["mailbox_with_no_mail"],"tags":[],"category":"Objects","description":"open mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📮","aliases":["postbox"],"tags":[],"category":"Objects","description":"postbox","unicode_version":"6.0"},{"emoji":"🗳️","aliases":["ballot_box"],"tags":[],"category":"Objects","description":"ballot box with ballot","unicode_version":"7.0"},{"emoji":"✏️","aliases":["pencil2"],"tags":[],"category":"Objects","description":"pencil","unicode_version":""},{"emoji":"✒️","aliases":["black_nib"],"tags":[],"category":"Objects","description":"black nib","unicode_version":""},{"emoji":"🖋️","aliases":["fountain_pen"],"tags":[],"category":"Objects","description":"fountain pen","unicode_version":"7.0"},{"emoji":"🖊️","aliases":["pen"],"tags":[],"category":"Objects","description":"pen","unicode_version":"7.0"},{"emoji":"🖌️","aliases":["paintbrush"],"tags":[],"category":"Objects","description":"paintbrush","unicode_version":"7.0"},{"emoji":"🖍️","aliases":["crayon"],"tags":[],"category":"Objects","description":"crayon","unicode_version":"7.0"},{"emoji":"📝","aliases":["memo","pencil"],"tags":["document","note"],"category":"Objects","description":"memo","unicode_version":"6.0"},{"emoji":"💼","aliases":["briefcase"],"tags":["business"],"category":"Objects","description":"briefcase","unicode_version":"6.0"},{"emoji":"📁","aliases":["file_folder"],"tags":["directory"],"category":"Objects","description":"file folder","unicode_version":"6.0"},{"emoji":"📂","aliases":["open_file_folder"],"tags":[],"category":"Objects","description":"open file folder","unicode_version":"6.0"},{"emoji":"🗂️","aliases":["card_index_dividers"],"tags":[],"category":"Objects","description":"card index dividers","unicode_version":"7.0"},{"emoji":"📅","aliases":["date"],"tags":["calendar","schedule"],"category":"Objects","description":"calendar","unicode_version":"6.0"},{"emoji":"📆","aliases":["calendar"],"tags":["schedule"],"category":"Objects","description":"tear-off calendar","unicode_version":"6.0"},{"emoji":"🗒️","aliases":["spiral_notepad"],"tags":[],"category":"Objects","description":"spiral notepad","unicode_version":"7.0"},{"emoji":"🗓️","aliases":["spiral_calendar"],"tags":[],"category":"Objects","description":"spiral calendar","unicode_version":"7.0"},{"emoji":"📇","aliases":["card_index"],"tags":[],"category":"Objects","description":"card index","unicode_version":"6.0"},{"emoji":"📈","aliases":["chart_with_upwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart increasing","unicode_version":"6.0"},{"emoji":"📉","aliases":["chart_with_downwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart decreasing","unicode_version":"6.0"},{"emoji":"📊","aliases":["bar_chart"],"tags":["stats","metrics"],"category":"Objects","description":"bar chart","unicode_version":"6.0"},{"emoji":"📋","aliases":["clipboard"],"tags":[],"category":"Objects","description":"clipboard","unicode_version":"6.0"},{"emoji":"📌","aliases":["pushpin"],"tags":["location"],"category":"Objects","description":"pushpin","unicode_version":"6.0"},{"emoji":"📍","aliases":["round_pushpin"],"tags":["location"],"category":"Objects","description":"round pushpin","unicode_version":"6.0"},{"emoji":"📎","aliases":["paperclip"],"tags":[],"category":"Objects","description":"paperclip","unicode_version":"6.0"},{"emoji":"🖇️","aliases":["paperclips"],"tags":[],"category":"Objects","description":"linked paperclips","unicode_version":"7.0"},{"emoji":"📏","aliases":["straight_ruler"],"tags":[],"category":"Objects","description":"straight ruler","unicode_version":"6.0"},{"emoji":"📐","aliases":["triangular_ruler"],"tags":[],"category":"Objects","description":"triangular ruler","unicode_version":"6.0"},{"emoji":"✂️","aliases":["scissors"],"tags":["cut"],"category":"Objects","description":"scissors","unicode_version":""},{"emoji":"🗃️","aliases":["card_file_box"],"tags":[],"category":"Objects","description":"card file box","unicode_version":"7.0"},{"emoji":"🗄️","aliases":["file_cabinet"],"tags":[],"category":"Objects","description":"file cabinet","unicode_version":"7.0"},{"emoji":"🗑️","aliases":["wastebasket"],"tags":["trash"],"category":"Objects","description":"wastebasket","unicode_version":"7.0"},{"emoji":"🔒","aliases":["lock"],"tags":["security","private"],"category":"Objects","description":"locked","unicode_version":"6.0"},{"emoji":"🔓","aliases":["unlock"],"tags":["security"],"category":"Objects","description":"unlocked","unicode_version":"6.0"},{"emoji":"🔏","aliases":["lock_with_ink_pen"],"tags":[],"category":"Objects","description":"locked with pen","unicode_version":"6.0"},{"emoji":"🔐","aliases":["closed_lock_with_key"],"tags":["security"],"category":"Objects","description":"locked with key","unicode_version":"6.0"},{"emoji":"🔑","aliases":["key"],"tags":["lock","password"],"category":"Objects","description":"key","unicode_version":"6.0"},{"emoji":"🗝️","aliases":["old_key"],"tags":[],"category":"Objects","description":"old key","unicode_version":"7.0"},{"emoji":"🔨","aliases":["hammer"],"tags":["tool"],"category":"Objects","description":"hammer","unicode_version":"6.0"},{"emoji":"🪓","aliases":["axe"],"tags":[],"category":"Objects","description":"axe","unicode_version":"12.0"},{"emoji":"⛏️","aliases":["pick"],"tags":[],"category":"Objects","description":"pick","unicode_version":"5.2"},{"emoji":"⚒️","aliases":["hammer_and_pick"],"tags":[],"category":"Objects","description":"hammer and pick","unicode_version":"4.1"},{"emoji":"🛠️","aliases":["hammer_and_wrench"],"tags":[],"category":"Objects","description":"hammer and wrench","unicode_version":"7.0"},{"emoji":"🗡️","aliases":["dagger"],"tags":[],"category":"Objects","description":"dagger","unicode_version":"7.0"},{"emoji":"⚔️","aliases":["crossed_swords"],"tags":[],"category":"Objects","description":"crossed swords","unicode_version":"4.1"},{"emoji":"🔫","aliases":["gun"],"tags":["shoot","weapon"],"category":"Objects","description":"water pistol","unicode_version":"6.0"},{"emoji":"🪃","aliases":["boomerang"],"tags":[],"category":"Objects","description":"boomerang","unicode_version":"13.0"},{"emoji":"🏹","aliases":["bow_and_arrow"],"tags":["archery"],"category":"Objects","description":"bow and arrow","unicode_version":"8.0"},{"emoji":"🛡️","aliases":["shield"],"tags":[],"category":"Objects","description":"shield","unicode_version":"7.0"},{"emoji":"🪚","aliases":["carpentry_saw"],"tags":[],"category":"Objects","description":"carpentry saw","unicode_version":"13.0"},{"emoji":"🔧","aliases":["wrench"],"tags":["tool"],"category":"Objects","description":"wrench","unicode_version":"6.0"},{"emoji":"🪛","aliases":["screwdriver"],"tags":[],"category":"Objects","description":"screwdriver","unicode_version":"13.0"},{"emoji":"🔩","aliases":["nut_and_bolt"],"tags":[],"category":"Objects","description":"nut and bolt","unicode_version":"6.0"},{"emoji":"⚙️","aliases":["gear"],"tags":[],"category":"Objects","description":"gear","unicode_version":"4.1"},{"emoji":"🗜️","aliases":["clamp"],"tags":[],"category":"Objects","description":"clamp","unicode_version":"7.0"},{"emoji":"⚖️","aliases":["balance_scale"],"tags":[],"category":"Objects","description":"balance scale","unicode_version":"4.1"},{"emoji":"🦯","aliases":["probing_cane"],"tags":[],"category":"Objects","description":"white cane","unicode_version":"12.0"},{"emoji":"🔗","aliases":["link"],"tags":[],"category":"Objects","description":"link","unicode_version":"6.0"},{"emoji":"⛓️","aliases":["chains"],"tags":[],"category":"Objects","description":"chains","unicode_version":"5.2"},{"emoji":"🪝","aliases":["hook"],"tags":[],"category":"Objects","description":"hook","unicode_version":"13.0"},{"emoji":"🧰","aliases":["toolbox"],"tags":[],"category":"Objects","description":"toolbox","unicode_version":"11.0"},{"emoji":"🧲","aliases":["magnet"],"tags":[],"category":"Objects","description":"magnet","unicode_version":"11.0"},{"emoji":"🪜","aliases":["ladder"],"tags":[],"category":"Objects","description":"ladder","unicode_version":"13.0"},{"emoji":"⚗️","aliases":["alembic"],"tags":[],"category":"Objects","description":"alembic","unicode_version":"4.1"},{"emoji":"🧪","aliases":["test_tube"],"tags":[],"category":"Objects","description":"test tube","unicode_version":"11.0"},{"emoji":"🧫","aliases":["petri_dish"],"tags":[],"category":"Objects","description":"petri dish","unicode_version":"11.0"},{"emoji":"🧬","aliases":["dna"],"tags":[],"category":"Objects","description":"dna","unicode_version":"11.0"},{"emoji":"🔬","aliases":["microscope"],"tags":["science","laboratory","investigate"],"category":"Objects","description":"microscope","unicode_version":"6.0"},{"emoji":"🔭","aliases":["telescope"],"tags":[],"category":"Objects","description":"telescope","unicode_version":"6.0"},{"emoji":"📡","aliases":["satellite"],"tags":["signal"],"category":"Objects","description":"satellite antenna","unicode_version":"6.0"},{"emoji":"💉","aliases":["syringe"],"tags":["health","hospital","needle"],"category":"Objects","description":"syringe","unicode_version":"6.0"},{"emoji":"🩸","aliases":["drop_of_blood"],"tags":[],"category":"Objects","description":"drop of blood","unicode_version":"12.0"},{"emoji":"💊","aliases":["pill"],"tags":["health","medicine"],"category":"Objects","description":"pill","unicode_version":"6.0"},{"emoji":"🩹","aliases":["adhesive_bandage"],"tags":[],"category":"Objects","description":"adhesive bandage","unicode_version":"12.0"},{"emoji":"🩺","aliases":["stethoscope"],"tags":[],"category":"Objects","description":"stethoscope","unicode_version":"12.0"},{"emoji":"🚪","aliases":["door"],"tags":[],"category":"Objects","description":"door","unicode_version":"6.0"},{"emoji":"🛗","aliases":["elevator"],"tags":[],"category":"Objects","description":"elevator","unicode_version":"13.0"},{"emoji":"🪞","aliases":["mirror"],"tags":[],"category":"Objects","description":"mirror","unicode_version":"13.0"},{"emoji":"🪟","aliases":["window"],"tags":[],"category":"Objects","description":"window","unicode_version":"13.0"},{"emoji":"🛏️","aliases":["bed"],"tags":[],"category":"Objects","description":"bed","unicode_version":"7.0"},{"emoji":"🛋️","aliases":["couch_and_lamp"],"tags":[],"category":"Objects","description":"couch and lamp","unicode_version":"7.0"},{"emoji":"🪑","aliases":["chair"],"tags":[],"category":"Objects","description":"chair","unicode_version":"12.0"},{"emoji":"🚽","aliases":["toilet"],"tags":["wc"],"category":"Objects","description":"toilet","unicode_version":"6.0"},{"emoji":"🪠","aliases":["plunger"],"tags":[],"category":"Objects","description":"plunger","unicode_version":"13.0"},{"emoji":"🚿","aliases":["shower"],"tags":["bath"],"category":"Objects","description":"shower","unicode_version":"6.0"},{"emoji":"🛁","aliases":["bathtub"],"tags":[],"category":"Objects","description":"bathtub","unicode_version":"6.0"},{"emoji":"🪤","aliases":["mouse_trap"],"tags":[],"category":"Objects","description":"mouse trap","unicode_version":"13.0"},{"emoji":"🪒","aliases":["razor"],"tags":[],"category":"Objects","description":"razor","unicode_version":"12.0"},{"emoji":"🧴","aliases":["lotion_bottle"],"tags":[],"category":"Objects","description":"lotion bottle","unicode_version":"11.0"},{"emoji":"🧷","aliases":["safety_pin"],"tags":[],"category":"Objects","description":"safety pin","unicode_version":"11.0"},{"emoji":"🧹","aliases":["broom"],"tags":[],"category":"Objects","description":"broom","unicode_version":"11.0"},{"emoji":"🧺","aliases":["basket"],"tags":[],"category":"Objects","description":"basket","unicode_version":"11.0"},{"emoji":"🧻","aliases":["roll_of_paper"],"tags":["toilet"],"category":"Objects","description":"roll of paper","unicode_version":"11.0"},{"emoji":"🪣","aliases":["bucket"],"tags":[],"category":"Objects","description":"bucket","unicode_version":"13.0"},{"emoji":"🧼","aliases":["soap"],"tags":[],"category":"Objects","description":"soap","unicode_version":"11.0"},{"emoji":"🪥","aliases":["toothbrush"],"tags":[],"category":"Objects","description":"toothbrush","unicode_version":"13.0"},{"emoji":"🧽","aliases":["sponge"],"tags":[],"category":"Objects","description":"sponge","unicode_version":"11.0"},{"emoji":"🧯","aliases":["fire_extinguisher"],"tags":[],"category":"Objects","description":"fire extinguisher","unicode_version":"11.0"},{"emoji":"🛒","aliases":["shopping_cart"],"tags":[],"category":"Objects","description":"shopping cart","unicode_version":"9.0"},{"emoji":"🚬","aliases":["smoking"],"tags":["cigarette"],"category":"Objects","description":"cigarette","unicode_version":"6.0"},{"emoji":"⚰️","aliases":["coffin"],"tags":["funeral"],"category":"Objects","description":"coffin","unicode_version":"4.1"},{"emoji":"🪦","aliases":["headstone"],"tags":[],"category":"Objects","description":"headstone","unicode_version":"13.0"},{"emoji":"⚱️","aliases":["funeral_urn"],"tags":[],"category":"Objects","description":"funeral urn","unicode_version":"4.1"},{"emoji":"🗿","aliases":["moyai"],"tags":["stone"],"category":"Objects","description":"moai","unicode_version":"6.0"},{"emoji":"🪧","aliases":["placard"],"tags":[],"category":"Objects","description":"placard","unicode_version":"13.0"},{"emoji":"🏧","aliases":["atm"],"tags":[],"category":"Symbols","description":"ATM sign","unicode_version":"6.0"},{"emoji":"🚮","aliases":["put_litter_in_its_place"],"tags":[],"category":"Symbols","description":"litter in bin sign","unicode_version":"6.0"},{"emoji":"🚰","aliases":["potable_water"],"tags":[],"category":"Symbols","description":"potable water","unicode_version":"6.0"},{"emoji":"♿","aliases":["wheelchair"],"tags":["accessibility"],"category":"Symbols","description":"wheelchair symbol","unicode_version":"4.1"},{"emoji":"🚹","aliases":["mens"],"tags":[],"category":"Symbols","description":"men’s room","unicode_version":"6.0"},{"emoji":"🚺","aliases":["womens"],"tags":[],"category":"Symbols","description":"women’s room","unicode_version":"6.0"},{"emoji":"🚻","aliases":["restroom"],"tags":["toilet"],"category":"Symbols","description":"restroom","unicode_version":"6.0"},{"emoji":"🚼","aliases":["baby_symbol"],"tags":[],"category":"Symbols","description":"baby symbol","unicode_version":"6.0"},{"emoji":"🚾","aliases":["wc"],"tags":["toilet","restroom"],"category":"Symbols","description":"water closet","unicode_version":"6.0"},{"emoji":"🛂","aliases":["passport_control"],"tags":[],"category":"Symbols","description":"passport control","unicode_version":"6.0"},{"emoji":"🛃","aliases":["customs"],"tags":[],"category":"Symbols","description":"customs","unicode_version":"6.0"},{"emoji":"🛄","aliases":["baggage_claim"],"tags":["airport"],"category":"Symbols","description":"baggage claim","unicode_version":"6.0"},{"emoji":"🛅","aliases":["left_luggage"],"tags":[],"category":"Symbols","description":"left luggage","unicode_version":"6.0"},{"emoji":"⚠️","aliases":["warning"],"tags":["wip"],"category":"Symbols","description":"warning","unicode_version":"4.0"},{"emoji":"🚸","aliases":["children_crossing"],"tags":[],"category":"Symbols","description":"children crossing","unicode_version":"6.0"},{"emoji":"⛔","aliases":["no_entry"],"tags":["limit"],"category":"Symbols","description":"no entry","unicode_version":"5.2"},{"emoji":"🚫","aliases":["no_entry_sign"],"tags":["block","forbidden"],"category":"Symbols","description":"prohibited","unicode_version":"6.0"},{"emoji":"🚳","aliases":["no_bicycles"],"tags":[],"category":"Symbols","description":"no bicycles","unicode_version":"6.0"},{"emoji":"🚭","aliases":["no_smoking"],"tags":[],"category":"Symbols","description":"no smoking","unicode_version":"6.0"},{"emoji":"🚯","aliases":["do_not_litter"],"tags":[],"category":"Symbols","description":"no littering","unicode_version":"6.0"},{"emoji":"🚱","aliases":["non-potable_water"],"tags":[],"category":"Symbols","description":"non-potable water","unicode_version":"6.0"},{"emoji":"🚷","aliases":["no_pedestrians"],"tags":[],"category":"Symbols","description":"no pedestrians","unicode_version":"6.0"},{"emoji":"📵","aliases":["no_mobile_phones"],"tags":[],"category":"Symbols","description":"no mobile phones","unicode_version":"6.0"},{"emoji":"🔞","aliases":["underage"],"tags":[],"category":"Symbols","description":"no one under eighteen","unicode_version":"6.0"},{"emoji":"☢️","aliases":["radioactive"],"tags":[],"category":"Symbols","description":"radioactive","unicode_version":""},{"emoji":"☣️","aliases":["biohazard"],"tags":[],"category":"Symbols","description":"biohazard","unicode_version":""},{"emoji":"⬆️","aliases":["arrow_up"],"tags":[],"category":"Symbols","description":"up arrow","unicode_version":"4.0"},{"emoji":"↗️","aliases":["arrow_upper_right"],"tags":[],"category":"Symbols","description":"up-right arrow","unicode_version":""},{"emoji":"➡️","aliases":["arrow_right"],"tags":[],"category":"Symbols","description":"right arrow","unicode_version":""},{"emoji":"↘️","aliases":["arrow_lower_right"],"tags":[],"category":"Symbols","description":"down-right arrow","unicode_version":""},{"emoji":"⬇️","aliases":["arrow_down"],"tags":[],"category":"Symbols","description":"down arrow","unicode_version":"4.0"},{"emoji":"↙️","aliases":["arrow_lower_left"],"tags":[],"category":"Symbols","description":"down-left arrow","unicode_version":""},{"emoji":"⬅️","aliases":["arrow_left"],"tags":[],"category":"Symbols","description":"left arrow","unicode_version":"4.0"},{"emoji":"↖️","aliases":["arrow_upper_left"],"tags":[],"category":"Symbols","description":"up-left arrow","unicode_version":""},{"emoji":"↕️","aliases":["arrow_up_down"],"tags":[],"category":"Symbols","description":"up-down arrow","unicode_version":""},{"emoji":"↔️","aliases":["left_right_arrow"],"tags":[],"category":"Symbols","description":"left-right arrow","unicode_version":""},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"],"tags":["return"],"category":"Symbols","description":"right arrow curving left","unicode_version":""},{"emoji":"↪️","aliases":["arrow_right_hook"],"tags":[],"category":"Symbols","description":"left arrow curving right","unicode_version":""},{"emoji":"⤴️","aliases":["arrow_heading_up"],"tags":[],"category":"Symbols","description":"right arrow curving up","unicode_version":""},{"emoji":"⤵️","aliases":["arrow_heading_down"],"tags":[],"category":"Symbols","description":"right arrow curving down","unicode_version":""},{"emoji":"🔃","aliases":["arrows_clockwise"],"tags":[],"category":"Symbols","description":"clockwise vertical arrows","unicode_version":"6.0"},{"emoji":"🔄","aliases":["arrows_counterclockwise"],"tags":["sync"],"category":"Symbols","description":"counterclockwise arrows button","unicode_version":"6.0"},{"emoji":"🔙","aliases":["back"],"tags":[],"category":"Symbols","description":"BACK arrow","unicode_version":"6.0"},{"emoji":"🔚","aliases":["end"],"tags":[],"category":"Symbols","description":"END arrow","unicode_version":"6.0"},{"emoji":"🔛","aliases":["on"],"tags":[],"category":"Symbols","description":"ON! arrow","unicode_version":"6.0"},{"emoji":"🔜","aliases":["soon"],"tags":[],"category":"Symbols","description":"SOON arrow","unicode_version":"6.0"},{"emoji":"🔝","aliases":["top"],"tags":[],"category":"Symbols","description":"TOP arrow","unicode_version":"6.0"},{"emoji":"🛐","aliases":["place_of_worship"],"tags":[],"category":"Symbols","description":"place of worship","unicode_version":"8.0"},{"emoji":"⚛️","aliases":["atom_symbol"],"tags":[],"category":"Symbols","description":"atom symbol","unicode_version":"4.1"},{"emoji":"🕉️","aliases":["om"],"tags":[],"category":"Symbols","description":"om","unicode_version":"7.0"},{"emoji":"✡️","aliases":["star_of_david"],"tags":[],"category":"Symbols","description":"star of David","unicode_version":""},{"emoji":"☸️","aliases":["wheel_of_dharma"],"tags":[],"category":"Symbols","description":"wheel of dharma","unicode_version":""},{"emoji":"☯️","aliases":["yin_yang"],"tags":[],"category":"Symbols","description":"yin yang","unicode_version":""},{"emoji":"✝️","aliases":["latin_cross"],"tags":[],"category":"Symbols","description":"latin cross","unicode_version":""},{"emoji":"☦️","aliases":["orthodox_cross"],"tags":[],"category":"Symbols","description":"orthodox cross","unicode_version":""},{"emoji":"☪️","aliases":["star_and_crescent"],"tags":[],"category":"Symbols","description":"star and crescent","unicode_version":""},{"emoji":"☮️","aliases":["peace_symbol"],"tags":[],"category":"Symbols","description":"peace symbol","unicode_version":""},{"emoji":"🕎","aliases":["menorah"],"tags":[],"category":"Symbols","description":"menorah","unicode_version":"8.0"},{"emoji":"🔯","aliases":["six_pointed_star"],"tags":[],"category":"Symbols","description":"dotted six-pointed star","unicode_version":"6.0"},{"emoji":"♈","aliases":["aries"],"tags":[],"category":"Symbols","description":"Aries","unicode_version":""},{"emoji":"♉","aliases":["taurus"],"tags":[],"category":"Symbols","description":"Taurus","unicode_version":""},{"emoji":"♊","aliases":["gemini"],"tags":[],"category":"Symbols","description":"Gemini","unicode_version":""},{"emoji":"♋","aliases":["cancer"],"tags":[],"category":"Symbols","description":"Cancer","unicode_version":""},{"emoji":"♌","aliases":["leo"],"tags":[],"category":"Symbols","description":"Leo","unicode_version":""},{"emoji":"♍","aliases":["virgo"],"tags":[],"category":"Symbols","description":"Virgo","unicode_version":""},{"emoji":"♎","aliases":["libra"],"tags":[],"category":"Symbols","description":"Libra","unicode_version":""},{"emoji":"♏","aliases":["scorpius"],"tags":[],"category":"Symbols","description":"Scorpio","unicode_version":""},{"emoji":"♐","aliases":["sagittarius"],"tags":[],"category":"Symbols","description":"Sagittarius","unicode_version":""},{"emoji":"♑","aliases":["capricorn"],"tags":[],"category":"Symbols","description":"Capricorn","unicode_version":""},{"emoji":"♒","aliases":["aquarius"],"tags":[],"category":"Symbols","description":"Aquarius","unicode_version":""},{"emoji":"♓","aliases":["pisces"],"tags":[],"category":"Symbols","description":"Pisces","unicode_version":""},{"emoji":"⛎","aliases":["ophiuchus"],"tags":[],"category":"Symbols","description":"Ophiuchus","unicode_version":"6.0"},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"],"tags":["shuffle"],"category":"Symbols","description":"shuffle tracks button","unicode_version":"6.0"},{"emoji":"🔁","aliases":["repeat"],"tags":["loop"],"category":"Symbols","description":"repeat button","unicode_version":"6.0"},{"emoji":"🔂","aliases":["repeat_one"],"tags":[],"category":"Symbols","description":"repeat single button","unicode_version":"6.0"},{"emoji":"▶️","aliases":["arrow_forward"],"tags":[],"category":"Symbols","description":"play button","unicode_version":""},{"emoji":"⏩","aliases":["fast_forward"],"tags":[],"category":"Symbols","description":"fast-forward button","unicode_version":"6.0"},{"emoji":"⏭️","aliases":["next_track_button"],"tags":[],"category":"Symbols","description":"next track button","unicode_version":"6.0"},{"emoji":"⏯️","aliases":["play_or_pause_button"],"tags":[],"category":"Symbols","description":"play or pause button","unicode_version":"6.0"},{"emoji":"◀️","aliases":["arrow_backward"],"tags":[],"category":"Symbols","description":"reverse button","unicode_version":""},{"emoji":"⏪","aliases":["rewind"],"tags":[],"category":"Symbols","description":"fast reverse button","unicode_version":"6.0"},{"emoji":"⏮️","aliases":["previous_track_button"],"tags":[],"category":"Symbols","description":"last track button","unicode_version":"6.0"},{"emoji":"🔼","aliases":["arrow_up_small"],"tags":[],"category":"Symbols","description":"upwards button","unicode_version":"6.0"},{"emoji":"⏫","aliases":["arrow_double_up"],"tags":[],"category":"Symbols","description":"fast up button","unicode_version":"6.0"},{"emoji":"🔽","aliases":["arrow_down_small"],"tags":[],"category":"Symbols","description":"downwards button","unicode_version":"6.0"},{"emoji":"⏬","aliases":["arrow_double_down"],"tags":[],"category":"Symbols","description":"fast down button","unicode_version":"6.0"},{"emoji":"⏸️","aliases":["pause_button"],"tags":[],"category":"Symbols","description":"pause button","unicode_version":"7.0"},{"emoji":"⏹️","aliases":["stop_button"],"tags":[],"category":"Symbols","description":"stop button","unicode_version":"7.0"},{"emoji":"⏺️","aliases":["record_button"],"tags":[],"category":"Symbols","description":"record button","unicode_version":"7.0"},{"emoji":"⏏️","aliases":["eject_button"],"tags":[],"category":"Symbols","description":"eject button","unicode_version":"11.0"},{"emoji":"🎦","aliases":["cinema"],"tags":["film","movie"],"category":"Symbols","description":"cinema","unicode_version":"6.0"},{"emoji":"🔅","aliases":["low_brightness"],"tags":[],"category":"Symbols","description":"dim button","unicode_version":"6.0"},{"emoji":"🔆","aliases":["high_brightness"],"tags":[],"category":"Symbols","description":"bright button","unicode_version":"6.0"},{"emoji":"📶","aliases":["signal_strength"],"tags":["wifi"],"category":"Symbols","description":"antenna bars","unicode_version":"6.0"},{"emoji":"📳","aliases":["vibration_mode"],"tags":[],"category":"Symbols","description":"vibration mode","unicode_version":"6.0"},{"emoji":"📴","aliases":["mobile_phone_off"],"tags":["mute","off"],"category":"Symbols","description":"mobile phone off","unicode_version":"6.0"},{"emoji":"♀️","aliases":["female_sign"],"tags":[],"category":"Symbols","description":"female sign","unicode_version":"11.0"},{"emoji":"♂️","aliases":["male_sign"],"tags":[],"category":"Symbols","description":"male sign","unicode_version":"11.0"},{"emoji":"⚧️","aliases":["transgender_symbol"],"tags":[],"category":"Symbols","description":"transgender symbol","unicode_version":"13.0"},{"emoji":"✖️","aliases":["heavy_multiplication_x"],"tags":[],"category":"Symbols","description":"multiply","unicode_version":""},{"emoji":"➕","aliases":["heavy_plus_sign"],"tags":[],"category":"Symbols","description":"plus","unicode_version":"6.0"},{"emoji":"➖","aliases":["heavy_minus_sign"],"tags":[],"category":"Symbols","description":"minus","unicode_version":"6.0"},{"emoji":"➗","aliases":["heavy_division_sign"],"tags":[],"category":"Symbols","description":"divide","unicode_version":"6.0"},{"emoji":"♾️","aliases":["infinity"],"tags":[],"category":"Symbols","description":"infinity","unicode_version":"11.0"},{"emoji":"‼️","aliases":["bangbang"],"tags":[],"category":"Symbols","description":"double exclamation mark","unicode_version":""},{"emoji":"⁉️","aliases":["interrobang"],"tags":[],"category":"Symbols","description":"exclamation question mark","unicode_version":"3.0"},{"emoji":"❓","aliases":["question"],"tags":["confused"],"category":"Symbols","description":"red question mark","unicode_version":"6.0"},{"emoji":"❔","aliases":["grey_question"],"tags":[],"category":"Symbols","description":"white question mark","unicode_version":"6.0"},{"emoji":"❕","aliases":["grey_exclamation"],"tags":[],"category":"Symbols","description":"white exclamation mark","unicode_version":"6.0"},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"],"tags":["bang"],"category":"Symbols","description":"red exclamation mark","unicode_version":"5.2"},{"emoji":"〰️","aliases":["wavy_dash"],"tags":[],"category":"Symbols","description":"wavy dash","unicode_version":""},{"emoji":"💱","aliases":["currency_exchange"],"tags":[],"category":"Symbols","description":"currency exchange","unicode_version":"6.0"},{"emoji":"💲","aliases":["heavy_dollar_sign"],"tags":[],"category":"Symbols","description":"heavy dollar sign","unicode_version":"6.0"},{"emoji":"⚕️","aliases":["medical_symbol"],"tags":[],"category":"Symbols","description":"medical symbol","unicode_version":"11.0"},{"emoji":"♻️","aliases":["recycle"],"tags":["environment","green"],"category":"Symbols","description":"recycling symbol","unicode_version":"3.2"},{"emoji":"⚜️","aliases":["fleur_de_lis"],"tags":[],"category":"Symbols","description":"fleur-de-lis","unicode_version":"4.1"},{"emoji":"🔱","aliases":["trident"],"tags":[],"category":"Symbols","description":"trident emblem","unicode_version":"6.0"},{"emoji":"📛","aliases":["name_badge"],"tags":[],"category":"Symbols","description":"name badge","unicode_version":"6.0"},{"emoji":"🔰","aliases":["beginner"],"tags":[],"category":"Symbols","description":"Japanese symbol for beginner","unicode_version":"6.0"},{"emoji":"⭕","aliases":["o"],"tags":[],"category":"Symbols","description":"hollow red circle","unicode_version":"5.2"},{"emoji":"✅","aliases":["white_check_mark"],"tags":[],"category":"Symbols","description":"check mark button","unicode_version":"6.0"},{"emoji":"☑️","aliases":["ballot_box_with_check"],"tags":[],"category":"Symbols","description":"check box with check","unicode_version":""},{"emoji":"✔️","aliases":["heavy_check_mark"],"tags":[],"category":"Symbols","description":"check mark","unicode_version":""},{"emoji":"❌","aliases":["x"],"tags":[],"category":"Symbols","description":"cross mark","unicode_version":"6.0"},{"emoji":"❎","aliases":["negative_squared_cross_mark"],"tags":[],"category":"Symbols","description":"cross mark button","unicode_version":"6.0"},{"emoji":"➰","aliases":["curly_loop"],"tags":[],"category":"Symbols","description":"curly loop","unicode_version":"6.0"},{"emoji":"➿","aliases":["loop"],"tags":[],"category":"Symbols","description":"double curly loop","unicode_version":"6.0"},{"emoji":"〽️","aliases":["part_alternation_mark"],"tags":[],"category":"Symbols","description":"part alternation mark","unicode_version":"3.2"},{"emoji":"✳️","aliases":["eight_spoked_asterisk"],"tags":[],"category":"Symbols","description":"eight-spoked asterisk","unicode_version":""},{"emoji":"✴️","aliases":["eight_pointed_black_star"],"tags":[],"category":"Symbols","description":"eight-pointed star","unicode_version":""},{"emoji":"❇️","aliases":["sparkle"],"tags":[],"category":"Symbols","description":"sparkle","unicode_version":""},{"emoji":"©️","aliases":["copyright"],"tags":[],"category":"Symbols","description":"copyright","unicode_version":""},{"emoji":"®️","aliases":["registered"],"tags":[],"category":"Symbols","description":"registered","unicode_version":""},{"emoji":"™️","aliases":["tm"],"tags":["trademark"],"category":"Symbols","description":"trade mark","unicode_version":""},{"emoji":"#️⃣","aliases":["hash"],"tags":["number"],"category":"Symbols","description":"keycap: #","unicode_version":""},{"emoji":"*️⃣","aliases":["asterisk"],"tags":[],"category":"Symbols","description":"keycap: *","unicode_version":""},{"emoji":"0️⃣","aliases":["zero"],"tags":[],"category":"Symbols","description":"keycap: 0","unicode_version":""},{"emoji":"1️⃣","aliases":["one"],"tags":[],"category":"Symbols","description":"keycap: 1","unicode_version":""},{"emoji":"2️⃣","aliases":["two"],"tags":[],"category":"Symbols","description":"keycap: 2","unicode_version":""},{"emoji":"3️⃣","aliases":["three"],"tags":[],"category":"Symbols","description":"keycap: 3","unicode_version":""},{"emoji":"4️⃣","aliases":["four"],"tags":[],"category":"Symbols","description":"keycap: 4","unicode_version":""},{"emoji":"5️⃣","aliases":["five"],"tags":[],"category":"Symbols","description":"keycap: 5","unicode_version":""},{"emoji":"6️⃣","aliases":["six"],"tags":[],"category":"Symbols","description":"keycap: 6","unicode_version":""},{"emoji":"7️⃣","aliases":["seven"],"tags":[],"category":"Symbols","description":"keycap: 7","unicode_version":""},{"emoji":"8️⃣","aliases":["eight"],"tags":[],"category":"Symbols","description":"keycap: 8","unicode_version":""},{"emoji":"9️⃣","aliases":["nine"],"tags":[],"category":"Symbols","description":"keycap: 9","unicode_version":""},{"emoji":"🔟","aliases":["keycap_ten"],"tags":[],"category":"Symbols","description":"keycap: 10","unicode_version":"6.0"},{"emoji":"🔠","aliases":["capital_abcd"],"tags":["letters"],"category":"Symbols","description":"input latin uppercase","unicode_version":"6.0"},{"emoji":"🔡","aliases":["abcd"],"tags":[],"category":"Symbols","description":"input latin lowercase","unicode_version":"6.0"},{"emoji":"🔢","aliases":["1234"],"tags":["numbers"],"category":"Symbols","description":"input numbers","unicode_version":"6.0"},{"emoji":"🔣","aliases":["symbols"],"tags":[],"category":"Symbols","description":"input symbols","unicode_version":"6.0"},{"emoji":"🔤","aliases":["abc"],"tags":["alphabet"],"category":"Symbols","description":"input latin letters","unicode_version":"6.0"},{"emoji":"🅰️","aliases":["a"],"tags":[],"category":"Symbols","description":"A button (blood type)","unicode_version":"6.0"},{"emoji":"🆎","aliases":["ab"],"tags":[],"category":"Symbols","description":"AB button (blood type)","unicode_version":"6.0"},{"emoji":"🅱️","aliases":["b"],"tags":[],"category":"Symbols","description":"B button (blood type)","unicode_version":"6.0"},{"emoji":"🆑","aliases":["cl"],"tags":[],"category":"Symbols","description":"CL button","unicode_version":"6.0"},{"emoji":"🆒","aliases":["cool"],"tags":[],"category":"Symbols","description":"COOL button","unicode_version":"6.0"},{"emoji":"🆓","aliases":["free"],"tags":[],"category":"Symbols","description":"FREE button","unicode_version":"6.0"},{"emoji":"ℹ️","aliases":["information_source"],"tags":[],"category":"Symbols","description":"information","unicode_version":"3.0"},{"emoji":"🆔","aliases":["id"],"tags":[],"category":"Symbols","description":"ID button","unicode_version":"6.0"},{"emoji":"Ⓜ️","aliases":["m"],"tags":[],"category":"Symbols","description":"circled M","unicode_version":""},{"emoji":"🆕","aliases":["new"],"tags":["fresh"],"category":"Symbols","description":"NEW button","unicode_version":"6.0"},{"emoji":"🆖","aliases":["ng"],"tags":[],"category":"Symbols","description":"NG button","unicode_version":"6.0"},{"emoji":"🅾️","aliases":["o2"],"tags":[],"category":"Symbols","description":"O button (blood type)","unicode_version":"6.0"},{"emoji":"🆗","aliases":["ok"],"tags":["yes"],"category":"Symbols","description":"OK button","unicode_version":"6.0"},{"emoji":"🅿️","aliases":["parking"],"tags":[],"category":"Symbols","description":"P button","unicode_version":"5.2"},{"emoji":"🆘","aliases":["sos"],"tags":["help","emergency"],"category":"Symbols","description":"SOS button","unicode_version":"6.0"},{"emoji":"🆙","aliases":["up"],"tags":[],"category":"Symbols","description":"UP! button","unicode_version":"6.0"},{"emoji":"🆚","aliases":["vs"],"tags":[],"category":"Symbols","description":"VS button","unicode_version":"6.0"},{"emoji":"🈁","aliases":["koko"],"tags":[],"category":"Symbols","description":"Japanese “here” button","unicode_version":"6.0"},{"emoji":"🈂️","aliases":["sa"],"tags":[],"category":"Symbols","description":"Japanese “service charge” button","unicode_version":"6.0"},{"emoji":"🈷️","aliases":["u6708"],"tags":[],"category":"Symbols","description":"Japanese “monthly amount” button","unicode_version":"6.0"},{"emoji":"🈶","aliases":["u6709"],"tags":[],"category":"Symbols","description":"Japanese “not free of charge” button","unicode_version":"6.0"},{"emoji":"🈯","aliases":["u6307"],"tags":[],"category":"Symbols","description":"Japanese “reserved” button","unicode_version":""},{"emoji":"🉐","aliases":["ideograph_advantage"],"tags":[],"category":"Symbols","description":"Japanese “bargain” button","unicode_version":"6.0"},{"emoji":"🈹","aliases":["u5272"],"tags":[],"category":"Symbols","description":"Japanese “discount” button","unicode_version":"6.0"},{"emoji":"🈚","aliases":["u7121"],"tags":[],"category":"Symbols","description":"Japanese “free of charge” button","unicode_version":""},{"emoji":"🈲","aliases":["u7981"],"tags":[],"category":"Symbols","description":"Japanese “prohibited” button","unicode_version":"6.0"},{"emoji":"🉑","aliases":["accept"],"tags":[],"category":"Symbols","description":"Japanese “acceptable” button","unicode_version":"6.0"},{"emoji":"🈸","aliases":["u7533"],"tags":[],"category":"Symbols","description":"Japanese “application” button","unicode_version":"6.0"},{"emoji":"🈴","aliases":["u5408"],"tags":[],"category":"Symbols","description":"Japanese “passing grade” button","unicode_version":"6.0"},{"emoji":"🈳","aliases":["u7a7a"],"tags":[],"category":"Symbols","description":"Japanese “vacancy” button","unicode_version":"6.0"},{"emoji":"㊗️","aliases":["congratulations"],"tags":[],"category":"Symbols","description":"Japanese “congratulations” button","unicode_version":""},{"emoji":"㊙️","aliases":["secret"],"tags":[],"category":"Symbols","description":"Japanese “secret” button","unicode_version":""},{"emoji":"🈺","aliases":["u55b6"],"tags":[],"category":"Symbols","description":"Japanese “open for business” button","unicode_version":"6.0"},{"emoji":"🈵","aliases":["u6e80"],"tags":[],"category":"Symbols","description":"Japanese “no vacancy” button","unicode_version":"6.0"},{"emoji":"🔴","aliases":["red_circle"],"tags":[],"category":"Symbols","description":"red circle","unicode_version":"6.0"},{"emoji":"🟠","aliases":["orange_circle"],"tags":[],"category":"Symbols","description":"orange circle","unicode_version":"12.0"},{"emoji":"🟡","aliases":["yellow_circle"],"tags":[],"category":"Symbols","description":"yellow circle","unicode_version":"12.0"},{"emoji":"🟢","aliases":["green_circle"],"tags":[],"category":"Symbols","description":"green circle","unicode_version":"12.0"},{"emoji":"🔵","aliases":["large_blue_circle"],"tags":[],"category":"Symbols","description":"blue circle","unicode_version":"6.0"},{"emoji":"🟣","aliases":["purple_circle"],"tags":[],"category":"Symbols","description":"purple circle","unicode_version":"12.0"},{"emoji":"🟤","aliases":["brown_circle"],"tags":[],"category":"Symbols","description":"brown circle","unicode_version":"12.0"},{"emoji":"⚫","aliases":["black_circle"],"tags":[],"category":"Symbols","description":"black circle","unicode_version":"4.1"},{"emoji":"⚪","aliases":["white_circle"],"tags":[],"category":"Symbols","description":"white circle","unicode_version":"4.1"},{"emoji":"🟥","aliases":["red_square"],"tags":[],"category":"Symbols","description":"red square","unicode_version":"12.0"},{"emoji":"🟧","aliases":["orange_square"],"tags":[],"category":"Symbols","description":"orange square","unicode_version":"12.0"},{"emoji":"🟨","aliases":["yellow_square"],"tags":[],"category":"Symbols","description":"yellow square","unicode_version":"12.0"},{"emoji":"🟩","aliases":["green_square"],"tags":[],"category":"Symbols","description":"green square","unicode_version":"12.0"},{"emoji":"🟦","aliases":["blue_square"],"tags":[],"category":"Symbols","description":"blue square","unicode_version":"12.0"},{"emoji":"🟪","aliases":["purple_square"],"tags":[],"category":"Symbols","description":"purple square","unicode_version":"12.0"},{"emoji":"🟫","aliases":["brown_square"],"tags":[],"category":"Symbols","description":"brown square","unicode_version":"12.0"},{"emoji":"⬛","aliases":["black_large_square"],"tags":[],"category":"Symbols","description":"black large square","unicode_version":"5.1"},{"emoji":"⬜","aliases":["white_large_square"],"tags":[],"category":"Symbols","description":"white large square","unicode_version":"5.1"},{"emoji":"◼️","aliases":["black_medium_square"],"tags":[],"category":"Symbols","description":"black medium square","unicode_version":"3.2"},{"emoji":"◻️","aliases":["white_medium_square"],"tags":[],"category":"Symbols","description":"white medium square","unicode_version":"3.2"},{"emoji":"◾","aliases":["black_medium_small_square"],"tags":[],"category":"Symbols","description":"black medium-small square","unicode_version":"3.2"},{"emoji":"◽","aliases":["white_medium_small_square"],"tags":[],"category":"Symbols","description":"white medium-small square","unicode_version":"3.2"},{"emoji":"▪️","aliases":["black_small_square"],"tags":[],"category":"Symbols","description":"black small square","unicode_version":""},{"emoji":"▫️","aliases":["white_small_square"],"tags":[],"category":"Symbols","description":"white small square","unicode_version":""},{"emoji":"🔶","aliases":["large_orange_diamond"],"tags":[],"category":"Symbols","description":"large orange diamond","unicode_version":"6.0"},{"emoji":"🔷","aliases":["large_blue_diamond"],"tags":[],"category":"Symbols","description":"large blue diamond","unicode_version":"6.0"},{"emoji":"🔸","aliases":["small_orange_diamond"],"tags":[],"category":"Symbols","description":"small orange diamond","unicode_version":"6.0"},{"emoji":"🔹","aliases":["small_blue_diamond"],"tags":[],"category":"Symbols","description":"small blue diamond","unicode_version":"6.0"},{"emoji":"🔺","aliases":["small_red_triangle"],"tags":[],"category":"Symbols","description":"red triangle pointed up","unicode_version":"6.0"},{"emoji":"🔻","aliases":["small_red_triangle_down"],"tags":[],"category":"Symbols","description":"red triangle pointed down","unicode_version":"6.0"},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"],"tags":[],"category":"Symbols","description":"diamond with a dot","unicode_version":"6.0"},{"emoji":"🔘","aliases":["radio_button"],"tags":[],"category":"Symbols","description":"radio button","unicode_version":"6.0"},{"emoji":"🔳","aliases":["white_square_button"],"tags":[],"category":"Symbols","description":"white square button","unicode_version":"6.0"},{"emoji":"🔲","aliases":["black_square_button"],"tags":[],"category":"Symbols","description":"black square button","unicode_version":"6.0"},{"emoji":"🏁","aliases":["checkered_flag"],"tags":["milestone","finish"],"category":"Flags","description":"chequered flag","unicode_version":"6.0"},{"emoji":"🚩","aliases":["triangular_flag_on_post"],"tags":[],"category":"Flags","description":"triangular flag","unicode_version":"6.0"},{"emoji":"🎌","aliases":["crossed_flags"],"tags":[],"category":"Flags","description":"crossed flags","unicode_version":"6.0"},{"emoji":"🏴","aliases":["black_flag"],"tags":[],"category":"Flags","description":"black flag","unicode_version":"7.0"},{"emoji":"🏳️","aliases":["white_flag"],"tags":[],"category":"Flags","description":"white flag","unicode_version":"7.0"},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"],"tags":["pride"],"category":"Flags","description":"rainbow flag","unicode_version":"6.0"},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"],"tags":[],"category":"Flags","description":"transgender flag","unicode_version":"13.0"},{"emoji":"🏴‍☠️","aliases":["pirate_flag"],"tags":[],"category":"Flags","description":"pirate flag","unicode_version":"11.0"},{"emoji":"🇦🇨","aliases":["ascension_island"],"tags":[],"category":"Flags","description":"flag: Ascension Island","unicode_version":"11.0"},{"emoji":"🇦🇩","aliases":["andorra"],"tags":[],"category":"Flags","description":"flag: Andorra","unicode_version":"6.0"},{"emoji":"🇦🇪","aliases":["united_arab_emirates"],"tags":[],"category":"Flags","description":"flag: United Arab Emirates","unicode_version":"6.0"},{"emoji":"🇦🇫","aliases":["afghanistan"],"tags":[],"category":"Flags","description":"flag: Afghanistan","unicode_version":"6.0"},{"emoji":"🇦🇬","aliases":["antigua_barbuda"],"tags":[],"category":"Flags","description":"flag: Antigua & Barbuda","unicode_version":"6.0"},{"emoji":"🇦🇮","aliases":["anguilla"],"tags":[],"category":"Flags","description":"flag: Anguilla","unicode_version":"6.0"},{"emoji":"🇦🇱","aliases":["albania"],"tags":[],"category":"Flags","description":"flag: Albania","unicode_version":"6.0"},{"emoji":"🇦🇲","aliases":["armenia"],"tags":[],"category":"Flags","description":"flag: Armenia","unicode_version":"6.0"},{"emoji":"🇦🇴","aliases":["angola"],"tags":[],"category":"Flags","description":"flag: Angola","unicode_version":"6.0"},{"emoji":"🇦🇶","aliases":["antarctica"],"tags":[],"category":"Flags","description":"flag: Antarctica","unicode_version":"6.0"},{"emoji":"🇦🇷","aliases":["argentina"],"tags":[],"category":"Flags","description":"flag: Argentina","unicode_version":"6.0"},{"emoji":"🇦🇸","aliases":["american_samoa"],"tags":[],"category":"Flags","description":"flag: American Samoa","unicode_version":"6.0"},{"emoji":"🇦🇹","aliases":["austria"],"tags":[],"category":"Flags","description":"flag: Austria","unicode_version":"6.0"},{"emoji":"🇦🇺","aliases":["australia"],"tags":[],"category":"Flags","description":"flag: Australia","unicode_version":"6.0"},{"emoji":"🇦🇼","aliases":["aruba"],"tags":[],"category":"Flags","description":"flag: Aruba","unicode_version":"6.0"},{"emoji":"🇦🇽","aliases":["aland_islands"],"tags":[],"category":"Flags","description":"flag: Åland Islands","unicode_version":"6.0"},{"emoji":"🇦🇿","aliases":["azerbaijan"],"tags":[],"category":"Flags","description":"flag: Azerbaijan","unicode_version":"6.0"},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"],"tags":[],"category":"Flags","description":"flag: Bosnia & Herzegovina","unicode_version":"6.0"},{"emoji":"🇧🇧","aliases":["barbados"],"tags":[],"category":"Flags","description":"flag: Barbados","unicode_version":"6.0"},{"emoji":"🇧🇩","aliases":["bangladesh"],"tags":[],"category":"Flags","description":"flag: Bangladesh","unicode_version":"6.0"},{"emoji":"🇧🇪","aliases":["belgium"],"tags":[],"category":"Flags","description":"flag: Belgium","unicode_version":"6.0"},{"emoji":"🇧🇫","aliases":["burkina_faso"],"tags":[],"category":"Flags","description":"flag: Burkina Faso","unicode_version":"6.0"},{"emoji":"🇧🇬","aliases":["bulgaria"],"tags":[],"category":"Flags","description":"flag: Bulgaria","unicode_version":"6.0"},{"emoji":"🇧🇭","aliases":["bahrain"],"tags":[],"category":"Flags","description":"flag: Bahrain","unicode_version":"6.0"},{"emoji":"🇧🇮","aliases":["burundi"],"tags":[],"category":"Flags","description":"flag: Burundi","unicode_version":"6.0"},{"emoji":"🇧🇯","aliases":["benin"],"tags":[],"category":"Flags","description":"flag: Benin","unicode_version":"6.0"},{"emoji":"🇧🇱","aliases":["st_barthelemy"],"tags":[],"category":"Flags","description":"flag: St. Barthélemy","unicode_version":"6.0"},{"emoji":"🇧🇲","aliases":["bermuda"],"tags":[],"category":"Flags","description":"flag: Bermuda","unicode_version":"6.0"},{"emoji":"🇧🇳","aliases":["brunei"],"tags":[],"category":"Flags","description":"flag: Brunei","unicode_version":"6.0"},{"emoji":"🇧🇴","aliases":["bolivia"],"tags":[],"category":"Flags","description":"flag: Bolivia","unicode_version":"6.0"},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"],"tags":[],"category":"Flags","description":"flag: Caribbean Netherlands","unicode_version":"6.0"},{"emoji":"🇧🇷","aliases":["brazil"],"tags":[],"category":"Flags","description":"flag: Brazil","unicode_version":"6.0"},{"emoji":"🇧🇸","aliases":["bahamas"],"tags":[],"category":"Flags","description":"flag: Bahamas","unicode_version":"6.0"},{"emoji":"🇧🇹","aliases":["bhutan"],"tags":[],"category":"Flags","description":"flag: Bhutan","unicode_version":"6.0"},{"emoji":"🇧🇻","aliases":["bouvet_island"],"tags":[],"category":"Flags","description":"flag: Bouvet Island","unicode_version":"11.0"},{"emoji":"🇧🇼","aliases":["botswana"],"tags":[],"category":"Flags","description":"flag: Botswana","unicode_version":"6.0"},{"emoji":"🇧🇾","aliases":["belarus"],"tags":[],"category":"Flags","description":"flag: Belarus","unicode_version":"6.0"},{"emoji":"🇧🇿","aliases":["belize"],"tags":[],"category":"Flags","description":"flag: Belize","unicode_version":"6.0"},{"emoji":"🇨🇦","aliases":["canada"],"tags":[],"category":"Flags","description":"flag: Canada","unicode_version":"6.0"},{"emoji":"🇨🇨","aliases":["cocos_islands"],"tags":["keeling"],"category":"Flags","description":"flag: Cocos (Keeling) Islands","unicode_version":"6.0"},{"emoji":"🇨🇩","aliases":["congo_kinshasa"],"tags":[],"category":"Flags","description":"flag: Congo - Kinshasa","unicode_version":"6.0"},{"emoji":"🇨🇫","aliases":["central_african_republic"],"tags":[],"category":"Flags","description":"flag: Central African Republic","unicode_version":"6.0"},{"emoji":"🇨🇬","aliases":["congo_brazzaville"],"tags":[],"category":"Flags","description":"flag: Congo - Brazzaville","unicode_version":"6.0"},{"emoji":"🇨🇭","aliases":["switzerland"],"tags":[],"category":"Flags","description":"flag: Switzerland","unicode_version":"6.0"},{"emoji":"🇨🇮","aliases":["cote_divoire"],"tags":["ivory"],"category":"Flags","description":"flag: Côte d’Ivoire","unicode_version":"6.0"},{"emoji":"🇨🇰","aliases":["cook_islands"],"tags":[],"category":"Flags","description":"flag: Cook Islands","unicode_version":"6.0"},{"emoji":"🇨🇱","aliases":["chile"],"tags":[],"category":"Flags","description":"flag: Chile","unicode_version":"6.0"},{"emoji":"🇨🇲","aliases":["cameroon"],"tags":[],"category":"Flags","description":"flag: Cameroon","unicode_version":"6.0"},{"emoji":"🇨🇳","aliases":["cn"],"tags":["china"],"category":"Flags","description":"flag: China","unicode_version":"6.0"},{"emoji":"🇨🇴","aliases":["colombia"],"tags":[],"category":"Flags","description":"flag: Colombia","unicode_version":"6.0"},{"emoji":"🇨🇵","aliases":["clipperton_island"],"tags":[],"category":"Flags","description":"flag: Clipperton Island","unicode_version":"11.0"},{"emoji":"🇨🇷","aliases":["costa_rica"],"tags":[],"category":"Flags","description":"flag: Costa Rica","unicode_version":"6.0"},{"emoji":"🇨🇺","aliases":["cuba"],"tags":[],"category":"Flags","description":"flag: Cuba","unicode_version":"6.0"},{"emoji":"🇨🇻","aliases":["cape_verde"],"tags":[],"category":"Flags","description":"flag: Cape Verde","unicode_version":"6.0"},{"emoji":"🇨🇼","aliases":["curacao"],"tags":[],"category":"Flags","description":"flag: Curaçao","unicode_version":"6.0"},{"emoji":"🇨🇽","aliases":["christmas_island"],"tags":[],"category":"Flags","description":"flag: Christmas Island","unicode_version":"6.0"},{"emoji":"🇨🇾","aliases":["cyprus"],"tags":[],"category":"Flags","description":"flag: Cyprus","unicode_version":"6.0"},{"emoji":"🇨🇿","aliases":["czech_republic"],"tags":[],"category":"Flags","description":"flag: Czechia","unicode_version":"6.0"},{"emoji":"🇩🇪","aliases":["de"],"tags":["flag","germany"],"category":"Flags","description":"flag: Germany","unicode_version":"6.0"},{"emoji":"🇩🇬","aliases":["diego_garcia"],"tags":[],"category":"Flags","description":"flag: Diego Garcia","unicode_version":"11.0"},{"emoji":"🇩🇯","aliases":["djibouti"],"tags":[],"category":"Flags","description":"flag: Djibouti","unicode_version":"6.0"},{"emoji":"🇩🇰","aliases":["denmark"],"tags":[],"category":"Flags","description":"flag: Denmark","unicode_version":"6.0"},{"emoji":"🇩🇲","aliases":["dominica"],"tags":[],"category":"Flags","description":"flag: Dominica","unicode_version":"6.0"},{"emoji":"🇩🇴","aliases":["dominican_republic"],"tags":[],"category":"Flags","description":"flag: Dominican Republic","unicode_version":"6.0"},{"emoji":"🇩🇿","aliases":["algeria"],"tags":[],"category":"Flags","description":"flag: Algeria","unicode_version":"6.0"},{"emoji":"🇪🇦","aliases":["ceuta_melilla"],"tags":[],"category":"Flags","description":"flag: Ceuta & Melilla","unicode_version":"11.0"},{"emoji":"🇪🇨","aliases":["ecuador"],"tags":[],"category":"Flags","description":"flag: Ecuador","unicode_version":"6.0"},{"emoji":"🇪🇪","aliases":["estonia"],"tags":[],"category":"Flags","description":"flag: Estonia","unicode_version":"6.0"},{"emoji":"🇪🇬","aliases":["egypt"],"tags":[],"category":"Flags","description":"flag: Egypt","unicode_version":"6.0"},{"emoji":"🇪🇭","aliases":["western_sahara"],"tags":[],"category":"Flags","description":"flag: Western Sahara","unicode_version":"6.0"},{"emoji":"🇪🇷","aliases":["eritrea"],"tags":[],"category":"Flags","description":"flag: Eritrea","unicode_version":"6.0"},{"emoji":"🇪🇸","aliases":["es"],"tags":["spain"],"category":"Flags","description":"flag: Spain","unicode_version":"6.0"},{"emoji":"🇪🇹","aliases":["ethiopia"],"tags":[],"category":"Flags","description":"flag: Ethiopia","unicode_version":"6.0"},{"emoji":"🇪🇺","aliases":["eu","european_union"],"tags":[],"category":"Flags","description":"flag: European Union","unicode_version":"6.0"},{"emoji":"🇫🇮","aliases":["finland"],"tags":[],"category":"Flags","description":"flag: Finland","unicode_version":"6.0"},{"emoji":"🇫🇯","aliases":["fiji"],"tags":[],"category":"Flags","description":"flag: Fiji","unicode_version":"6.0"},{"emoji":"🇫🇰","aliases":["falkland_islands"],"tags":[],"category":"Flags","description":"flag: Falkland Islands","unicode_version":"6.0"},{"emoji":"🇫🇲","aliases":["micronesia"],"tags":[],"category":"Flags","description":"flag: Micronesia","unicode_version":"6.0"},{"emoji":"🇫🇴","aliases":["faroe_islands"],"tags":[],"category":"Flags","description":"flag: Faroe Islands","unicode_version":"6.0"},{"emoji":"🇫🇷","aliases":["fr"],"tags":["france","french"],"category":"Flags","description":"flag: France","unicode_version":"6.0"},{"emoji":"🇬🇦","aliases":["gabon"],"tags":[],"category":"Flags","description":"flag: Gabon","unicode_version":"6.0"},{"emoji":"🇬🇧","aliases":["gb","uk"],"tags":["flag","british"],"category":"Flags","description":"flag: United Kingdom","unicode_version":"6.0"},{"emoji":"🇬🇩","aliases":["grenada"],"tags":[],"category":"Flags","description":"flag: Grenada","unicode_version":"6.0"},{"emoji":"🇬🇪","aliases":["georgia"],"tags":[],"category":"Flags","description":"flag: Georgia","unicode_version":"6.0"},{"emoji":"🇬🇫","aliases":["french_guiana"],"tags":[],"category":"Flags","description":"flag: French Guiana","unicode_version":"6.0"},{"emoji":"🇬🇬","aliases":["guernsey"],"tags":[],"category":"Flags","description":"flag: Guernsey","unicode_version":"6.0"},{"emoji":"🇬🇭","aliases":["ghana"],"tags":[],"category":"Flags","description":"flag: Ghana","unicode_version":"6.0"},{"emoji":"🇬🇮","aliases":["gibraltar"],"tags":[],"category":"Flags","description":"flag: Gibraltar","unicode_version":"6.0"},{"emoji":"🇬🇱","aliases":["greenland"],"tags":[],"category":"Flags","description":"flag: Greenland","unicode_version":"6.0"},{"emoji":"🇬🇲","aliases":["gambia"],"tags":[],"category":"Flags","description":"flag: Gambia","unicode_version":"6.0"},{"emoji":"🇬🇳","aliases":["guinea"],"tags":[],"category":"Flags","description":"flag: Guinea","unicode_version":"6.0"},{"emoji":"🇬🇵","aliases":["guadeloupe"],"tags":[],"category":"Flags","description":"flag: Guadeloupe","unicode_version":"6.0"},{"emoji":"🇬🇶","aliases":["equatorial_guinea"],"tags":[],"category":"Flags","description":"flag: Equatorial Guinea","unicode_version":"6.0"},{"emoji":"🇬🇷","aliases":["greece"],"tags":[],"category":"Flags","description":"flag: Greece","unicode_version":"6.0"},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"],"tags":[],"category":"Flags","description":"flag: South Georgia & South Sandwich Islands","unicode_version":"6.0"},{"emoji":"🇬🇹","aliases":["guatemala"],"tags":[],"category":"Flags","description":"flag: Guatemala","unicode_version":"6.0"},{"emoji":"🇬🇺","aliases":["guam"],"tags":[],"category":"Flags","description":"flag: Guam","unicode_version":"6.0"},{"emoji":"🇬🇼","aliases":["guinea_bissau"],"tags":[],"category":"Flags","description":"flag: Guinea-Bissau","unicode_version":"6.0"},{"emoji":"🇬🇾","aliases":["guyana"],"tags":[],"category":"Flags","description":"flag: Guyana","unicode_version":"6.0"},{"emoji":"🇭🇰","aliases":["hong_kong"],"tags":[],"category":"Flags","description":"flag: Hong Kong SAR China","unicode_version":"6.0"},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"],"tags":[],"category":"Flags","description":"flag: Heard & McDonald Islands","unicode_version":"11.0"},{"emoji":"🇭🇳","aliases":["honduras"],"tags":[],"category":"Flags","description":"flag: Honduras","unicode_version":"6.0"},{"emoji":"🇭🇷","aliases":["croatia"],"tags":[],"category":"Flags","description":"flag: Croatia","unicode_version":"6.0"},{"emoji":"🇭🇹","aliases":["haiti"],"tags":[],"category":"Flags","description":"flag: Haiti","unicode_version":"6.0"},{"emoji":"🇭🇺","aliases":["hungary"],"tags":[],"category":"Flags","description":"flag: Hungary","unicode_version":"6.0"},{"emoji":"🇮🇨","aliases":["canary_islands"],"tags":[],"category":"Flags","description":"flag: Canary Islands","unicode_version":"6.0"},{"emoji":"🇮🇩","aliases":["indonesia"],"tags":[],"category":"Flags","description":"flag: Indonesia","unicode_version":"6.0"},{"emoji":"🇮🇪","aliases":["ireland"],"tags":[],"category":"Flags","description":"flag: Ireland","unicode_version":"6.0"},{"emoji":"🇮🇱","aliases":["israel"],"tags":[],"category":"Flags","description":"flag: Israel","unicode_version":"6.0"},{"emoji":"🇮🇲","aliases":["isle_of_man"],"tags":[],"category":"Flags","description":"flag: Isle of Man","unicode_version":"6.0"},{"emoji":"🇮🇳","aliases":["india"],"tags":[],"category":"Flags","description":"flag: India","unicode_version":"6.0"},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"],"tags":[],"category":"Flags","description":"flag: British Indian Ocean Territory","unicode_version":"6.0"},{"emoji":"🇮🇶","aliases":["iraq"],"tags":[],"category":"Flags","description":"flag: Iraq","unicode_version":"6.0"},{"emoji":"🇮🇷","aliases":["iran"],"tags":[],"category":"Flags","description":"flag: Iran","unicode_version":"6.0"},{"emoji":"🇮🇸","aliases":["iceland"],"tags":[],"category":"Flags","description":"flag: Iceland","unicode_version":"6.0"},{"emoji":"🇮🇹","aliases":["it"],"tags":["italy"],"category":"Flags","description":"flag: Italy","unicode_version":"6.0"},{"emoji":"🇯🇪","aliases":["jersey"],"tags":[],"category":"Flags","description":"flag: Jersey","unicode_version":"6.0"},{"emoji":"🇯🇲","aliases":["jamaica"],"tags":[],"category":"Flags","description":"flag: Jamaica","unicode_version":"6.0"},{"emoji":"🇯🇴","aliases":["jordan"],"tags":[],"category":"Flags","description":"flag: Jordan","unicode_version":"6.0"},{"emoji":"🇯🇵","aliases":["jp"],"tags":["japan"],"category":"Flags","description":"flag: Japan","unicode_version":"6.0"},{"emoji":"🇰🇪","aliases":["kenya"],"tags":[],"category":"Flags","description":"flag: Kenya","unicode_version":"6.0"},{"emoji":"🇰🇬","aliases":["kyrgyzstan"],"tags":[],"category":"Flags","description":"flag: Kyrgyzstan","unicode_version":"6.0"},{"emoji":"🇰🇭","aliases":["cambodia"],"tags":[],"category":"Flags","description":"flag: Cambodia","unicode_version":"6.0"},{"emoji":"🇰🇮","aliases":["kiribati"],"tags":[],"category":"Flags","description":"flag: Kiribati","unicode_version":"6.0"},{"emoji":"🇰🇲","aliases":["comoros"],"tags":[],"category":"Flags","description":"flag: Comoros","unicode_version":"6.0"},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"],"tags":[],"category":"Flags","description":"flag: St. Kitts & Nevis","unicode_version":"6.0"},{"emoji":"🇰🇵","aliases":["north_korea"],"tags":[],"category":"Flags","description":"flag: North Korea","unicode_version":"6.0"},{"emoji":"🇰🇷","aliases":["kr"],"tags":["korea"],"category":"Flags","description":"flag: South Korea","unicode_version":"6.0"},{"emoji":"🇰🇼","aliases":["kuwait"],"tags":[],"category":"Flags","description":"flag: Kuwait","unicode_version":"6.0"},{"emoji":"🇰🇾","aliases":["cayman_islands"],"tags":[],"category":"Flags","description":"flag: Cayman Islands","unicode_version":"6.0"},{"emoji":"🇰🇿","aliases":["kazakhstan"],"tags":[],"category":"Flags","description":"flag: Kazakhstan","unicode_version":"6.0"},{"emoji":"🇱🇦","aliases":["laos"],"tags":[],"category":"Flags","description":"flag: Laos","unicode_version":"6.0"},{"emoji":"🇱🇧","aliases":["lebanon"],"tags":[],"category":"Flags","description":"flag: Lebanon","unicode_version":"6.0"},{"emoji":"🇱🇨","aliases":["st_lucia"],"tags":[],"category":"Flags","description":"flag: St. Lucia","unicode_version":"6.0"},{"emoji":"🇱🇮","aliases":["liechtenstein"],"tags":[],"category":"Flags","description":"flag: Liechtenstein","unicode_version":"6.0"},{"emoji":"🇱🇰","aliases":["sri_lanka"],"tags":[],"category":"Flags","description":"flag: Sri Lanka","unicode_version":"6.0"},{"emoji":"🇱🇷","aliases":["liberia"],"tags":[],"category":"Flags","description":"flag: Liberia","unicode_version":"6.0"},{"emoji":"🇱🇸","aliases":["lesotho"],"tags":[],"category":"Flags","description":"flag: Lesotho","unicode_version":"6.0"},{"emoji":"🇱🇹","aliases":["lithuania"],"tags":[],"category":"Flags","description":"flag: Lithuania","unicode_version":"6.0"},{"emoji":"🇱🇺","aliases":["luxembourg"],"tags":[],"category":"Flags","description":"flag: Luxembourg","unicode_version":"6.0"},{"emoji":"🇱🇻","aliases":["latvia"],"tags":[],"category":"Flags","description":"flag: Latvia","unicode_version":"6.0"},{"emoji":"🇱🇾","aliases":["libya"],"tags":[],"category":"Flags","description":"flag: Libya","unicode_version":"6.0"},{"emoji":"🇲🇦","aliases":["morocco"],"tags":[],"category":"Flags","description":"flag: Morocco","unicode_version":"6.0"},{"emoji":"🇲🇨","aliases":["monaco"],"tags":[],"category":"Flags","description":"flag: Monaco","unicode_version":"6.0"},{"emoji":"🇲🇩","aliases":["moldova"],"tags":[],"category":"Flags","description":"flag: Moldova","unicode_version":"6.0"},{"emoji":"🇲🇪","aliases":["montenegro"],"tags":[],"category":"Flags","description":"flag: Montenegro","unicode_version":"6.0"},{"emoji":"🇲🇫","aliases":["st_martin"],"tags":[],"category":"Flags","description":"flag: St. Martin","unicode_version":"11.0"},{"emoji":"🇲🇬","aliases":["madagascar"],"tags":[],"category":"Flags","description":"flag: Madagascar","unicode_version":"6.0"},{"emoji":"🇲🇭","aliases":["marshall_islands"],"tags":[],"category":"Flags","description":"flag: Marshall Islands","unicode_version":"6.0"},{"emoji":"🇲🇰","aliases":["macedonia"],"tags":[],"category":"Flags","description":"flag: North Macedonia","unicode_version":"6.0"},{"emoji":"🇲🇱","aliases":["mali"],"tags":[],"category":"Flags","description":"flag: Mali","unicode_version":"6.0"},{"emoji":"🇲🇲","aliases":["myanmar"],"tags":["burma"],"category":"Flags","description":"flag: Myanmar (Burma)","unicode_version":"6.0"},{"emoji":"🇲🇳","aliases":["mongolia"],"tags":[],"category":"Flags","description":"flag: Mongolia","unicode_version":"6.0"},{"emoji":"🇲🇴","aliases":["macau"],"tags":[],"category":"Flags","description":"flag: Macao SAR China","unicode_version":"6.0"},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"],"tags":[],"category":"Flags","description":"flag: Northern Mariana Islands","unicode_version":"6.0"},{"emoji":"🇲🇶","aliases":["martinique"],"tags":[],"category":"Flags","description":"flag: Martinique","unicode_version":"6.0"},{"emoji":"🇲🇷","aliases":["mauritania"],"tags":[],"category":"Flags","description":"flag: Mauritania","unicode_version":"6.0"},{"emoji":"🇲🇸","aliases":["montserrat"],"tags":[],"category":"Flags","description":"flag: Montserrat","unicode_version":"6.0"},{"emoji":"🇲🇹","aliases":["malta"],"tags":[],"category":"Flags","description":"flag: Malta","unicode_version":"6.0"},{"emoji":"🇲🇺","aliases":["mauritius"],"tags":[],"category":"Flags","description":"flag: Mauritius","unicode_version":"6.0"},{"emoji":"🇲🇻","aliases":["maldives"],"tags":[],"category":"Flags","description":"flag: Maldives","unicode_version":"6.0"},{"emoji":"🇲🇼","aliases":["malawi"],"tags":[],"category":"Flags","description":"flag: Malawi","unicode_version":"6.0"},{"emoji":"🇲🇽","aliases":["mexico"],"tags":[],"category":"Flags","description":"flag: Mexico","unicode_version":"6.0"},{"emoji":"🇲🇾","aliases":["malaysia"],"tags":[],"category":"Flags","description":"flag: Malaysia","unicode_version":"6.0"},{"emoji":"🇲🇿","aliases":["mozambique"],"tags":[],"category":"Flags","description":"flag: Mozambique","unicode_version":"6.0"},{"emoji":"🇳🇦","aliases":["namibia"],"tags":[],"category":"Flags","description":"flag: Namibia","unicode_version":"6.0"},{"emoji":"🇳🇨","aliases":["new_caledonia"],"tags":[],"category":"Flags","description":"flag: New Caledonia","unicode_version":"6.0"},{"emoji":"🇳🇪","aliases":["niger"],"tags":[],"category":"Flags","description":"flag: Niger","unicode_version":"6.0"},{"emoji":"🇳🇫","aliases":["norfolk_island"],"tags":[],"category":"Flags","description":"flag: Norfolk Island","unicode_version":"6.0"},{"emoji":"🇳🇬","aliases":["nigeria"],"tags":[],"category":"Flags","description":"flag: Nigeria","unicode_version":"6.0"},{"emoji":"🇳🇮","aliases":["nicaragua"],"tags":[],"category":"Flags","description":"flag: Nicaragua","unicode_version":"6.0"},{"emoji":"🇳🇱","aliases":["netherlands"],"tags":[],"category":"Flags","description":"flag: Netherlands","unicode_version":"6.0"},{"emoji":"🇳🇴","aliases":["norway"],"tags":[],"category":"Flags","description":"flag: Norway","unicode_version":"6.0"},{"emoji":"🇳🇵","aliases":["nepal"],"tags":[],"category":"Flags","description":"flag: Nepal","unicode_version":"6.0"},{"emoji":"🇳🇷","aliases":["nauru"],"tags":[],"category":"Flags","description":"flag: Nauru","unicode_version":"6.0"},{"emoji":"🇳🇺","aliases":["niue"],"tags":[],"category":"Flags","description":"flag: Niue","unicode_version":"6.0"},{"emoji":"🇳🇿","aliases":["new_zealand"],"tags":[],"category":"Flags","description":"flag: New Zealand","unicode_version":"6.0"},{"emoji":"🇴🇲","aliases":["oman"],"tags":[],"category":"Flags","description":"flag: Oman","unicode_version":"6.0"},{"emoji":"🇵🇦","aliases":["panama"],"tags":[],"category":"Flags","description":"flag: Panama","unicode_version":"6.0"},{"emoji":"🇵🇪","aliases":["peru"],"tags":[],"category":"Flags","description":"flag: Peru","unicode_version":"6.0"},{"emoji":"🇵🇫","aliases":["french_polynesia"],"tags":[],"category":"Flags","description":"flag: French Polynesia","unicode_version":"6.0"},{"emoji":"🇵🇬","aliases":["papua_new_guinea"],"tags":[],"category":"Flags","description":"flag: Papua New Guinea","unicode_version":"6.0"},{"emoji":"🇵🇭","aliases":["philippines"],"tags":[],"category":"Flags","description":"flag: Philippines","unicode_version":"6.0"},{"emoji":"🇵🇰","aliases":["pakistan"],"tags":[],"category":"Flags","description":"flag: Pakistan","unicode_version":"6.0"},{"emoji":"🇵🇱","aliases":["poland"],"tags":[],"category":"Flags","description":"flag: Poland","unicode_version":"6.0"},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"],"tags":[],"category":"Flags","description":"flag: St. Pierre & Miquelon","unicode_version":"6.0"},{"emoji":"🇵🇳","aliases":["pitcairn_islands"],"tags":[],"category":"Flags","description":"flag: Pitcairn Islands","unicode_version":"6.0"},{"emoji":"🇵🇷","aliases":["puerto_rico"],"tags":[],"category":"Flags","description":"flag: Puerto Rico","unicode_version":"6.0"},{"emoji":"🇵🇸","aliases":["palestinian_territories"],"tags":[],"category":"Flags","description":"flag: Palestinian Territories","unicode_version":"6.0"},{"emoji":"🇵🇹","aliases":["portugal"],"tags":[],"category":"Flags","description":"flag: Portugal","unicode_version":"6.0"},{"emoji":"🇵🇼","aliases":["palau"],"tags":[],"category":"Flags","description":"flag: Palau","unicode_version":"6.0"},{"emoji":"🇵🇾","aliases":["paraguay"],"tags":[],"category":"Flags","description":"flag: Paraguay","unicode_version":"6.0"},{"emoji":"🇶🇦","aliases":["qatar"],"tags":[],"category":"Flags","description":"flag: Qatar","unicode_version":"6.0"},{"emoji":"🇷🇪","aliases":["reunion"],"tags":[],"category":"Flags","description":"flag: Réunion","unicode_version":"6.0"},{"emoji":"🇷🇴","aliases":["romania"],"tags":[],"category":"Flags","description":"flag: Romania","unicode_version":"6.0"},{"emoji":"🇷🇸","aliases":["serbia"],"tags":[],"category":"Flags","description":"flag: Serbia","unicode_version":"6.0"},{"emoji":"🇷🇺","aliases":["ru"],"tags":["russia"],"category":"Flags","description":"flag: Russia","unicode_version":"6.0"},{"emoji":"🇷🇼","aliases":["rwanda"],"tags":[],"category":"Flags","description":"flag: Rwanda","unicode_version":"6.0"},{"emoji":"🇸🇦","aliases":["saudi_arabia"],"tags":[],"category":"Flags","description":"flag: Saudi Arabia","unicode_version":"6.0"},{"emoji":"🇸🇧","aliases":["solomon_islands"],"tags":[],"category":"Flags","description":"flag: Solomon Islands","unicode_version":"6.0"},{"emoji":"🇸🇨","aliases":["seychelles"],"tags":[],"category":"Flags","description":"flag: Seychelles","unicode_version":"6.0"},{"emoji":"🇸🇩","aliases":["sudan"],"tags":[],"category":"Flags","description":"flag: Sudan","unicode_version":"6.0"},{"emoji":"🇸🇪","aliases":["sweden"],"tags":[],"category":"Flags","description":"flag: Sweden","unicode_version":"6.0"},{"emoji":"🇸🇬","aliases":["singapore"],"tags":[],"category":"Flags","description":"flag: Singapore","unicode_version":"6.0"},{"emoji":"🇸🇭","aliases":["st_helena"],"tags":[],"category":"Flags","description":"flag: St. Helena","unicode_version":"6.0"},{"emoji":"🇸🇮","aliases":["slovenia"],"tags":[],"category":"Flags","description":"flag: Slovenia","unicode_version":"6.0"},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"],"tags":[],"category":"Flags","description":"flag: Svalbard & Jan Mayen","unicode_version":"11.0"},{"emoji":"🇸🇰","aliases":["slovakia"],"tags":[],"category":"Flags","description":"flag: Slovakia","unicode_version":"6.0"},{"emoji":"🇸🇱","aliases":["sierra_leone"],"tags":[],"category":"Flags","description":"flag: Sierra Leone","unicode_version":"6.0"},{"emoji":"🇸🇲","aliases":["san_marino"],"tags":[],"category":"Flags","description":"flag: San Marino","unicode_version":"6.0"},{"emoji":"🇸🇳","aliases":["senegal"],"tags":[],"category":"Flags","description":"flag: Senegal","unicode_version":"6.0"},{"emoji":"🇸🇴","aliases":["somalia"],"tags":[],"category":"Flags","description":"flag: Somalia","unicode_version":"6.0"},{"emoji":"🇸🇷","aliases":["suriname"],"tags":[],"category":"Flags","description":"flag: Suriname","unicode_version":"6.0"},{"emoji":"🇸🇸","aliases":["south_sudan"],"tags":[],"category":"Flags","description":"flag: South Sudan","unicode_version":"6.0"},{"emoji":"🇸🇹","aliases":["sao_tome_principe"],"tags":[],"category":"Flags","description":"flag: São Tomé & Príncipe","unicode_version":"6.0"},{"emoji":"🇸🇻","aliases":["el_salvador"],"tags":[],"category":"Flags","description":"flag: El Salvador","unicode_version":"6.0"},{"emoji":"🇸🇽","aliases":["sint_maarten"],"tags":[],"category":"Flags","description":"flag: Sint Maarten","unicode_version":"6.0"},{"emoji":"🇸🇾","aliases":["syria"],"tags":[],"category":"Flags","description":"flag: Syria","unicode_version":"6.0"},{"emoji":"🇸🇿","aliases":["swaziland"],"tags":[],"category":"Flags","description":"flag: Eswatini","unicode_version":"6.0"},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"],"tags":[],"category":"Flags","description":"flag: Tristan da Cunha","unicode_version":"11.0"},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"],"tags":[],"category":"Flags","description":"flag: Turks & Caicos Islands","unicode_version":"6.0"},{"emoji":"🇹🇩","aliases":["chad"],"tags":[],"category":"Flags","description":"flag: Chad","unicode_version":"6.0"},{"emoji":"🇹🇫","aliases":["french_southern_territories"],"tags":[],"category":"Flags","description":"flag: French Southern Territories","unicode_version":"6.0"},{"emoji":"🇹🇬","aliases":["togo"],"tags":[],"category":"Flags","description":"flag: Togo","unicode_version":"6.0"},{"emoji":"🇹🇭","aliases":["thailand"],"tags":[],"category":"Flags","description":"flag: Thailand","unicode_version":"6.0"},{"emoji":"🇹🇯","aliases":["tajikistan"],"tags":[],"category":"Flags","description":"flag: Tajikistan","unicode_version":"6.0"},{"emoji":"🇹🇰","aliases":["tokelau"],"tags":[],"category":"Flags","description":"flag: Tokelau","unicode_version":"6.0"},{"emoji":"🇹🇱","aliases":["timor_leste"],"tags":[],"category":"Flags","description":"flag: Timor-Leste","unicode_version":"6.0"},{"emoji":"🇹🇲","aliases":["turkmenistan"],"tags":[],"category":"Flags","description":"flag: Turkmenistan","unicode_version":"6.0"},{"emoji":"🇹🇳","aliases":["tunisia"],"tags":[],"category":"Flags","description":"flag: Tunisia","unicode_version":"6.0"},{"emoji":"🇹🇴","aliases":["tonga"],"tags":[],"category":"Flags","description":"flag: Tonga","unicode_version":"6.0"},{"emoji":"🇹🇷","aliases":["tr"],"tags":["turkey"],"category":"Flags","description":"flag: Turkey","unicode_version":"8.0"},{"emoji":"🇹🇹","aliases":["trinidad_tobago"],"tags":[],"category":"Flags","description":"flag: Trinidad & Tobago","unicode_version":"6.0"},{"emoji":"🇹🇻","aliases":["tuvalu"],"tags":[],"category":"Flags","description":"flag: Tuvalu","unicode_version":"6.0"},{"emoji":"🇹🇼","aliases":["taiwan"],"tags":[],"category":"Flags","description":"flag: Taiwan","unicode_version":"6.0"},{"emoji":"🇹🇿","aliases":["tanzania"],"tags":[],"category":"Flags","description":"flag: Tanzania","unicode_version":"6.0"},{"emoji":"🇺🇦","aliases":["ukraine"],"tags":[],"category":"Flags","description":"flag: Ukraine","unicode_version":"6.0"},{"emoji":"🇺🇬","aliases":["uganda"],"tags":[],"category":"Flags","description":"flag: Uganda","unicode_version":"6.0"},{"emoji":"🇺🇲","aliases":["us_outlying_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Outlying Islands","unicode_version":"11.0"},{"emoji":"🇺🇳","aliases":["united_nations"],"tags":[],"category":"Flags","description":"flag: United Nations","unicode_version":"11.0"},{"emoji":"🇺🇸","aliases":["us"],"tags":["flag","united","america"],"category":"Flags","description":"flag: United States","unicode_version":"6.0"},{"emoji":"🇺🇾","aliases":["uruguay"],"tags":[],"category":"Flags","description":"flag: Uruguay","unicode_version":"6.0"},{"emoji":"🇺🇿","aliases":["uzbekistan"],"tags":[],"category":"Flags","description":"flag: Uzbekistan","unicode_version":"6.0"},{"emoji":"🇻🇦","aliases":["vatican_city"],"tags":[],"category":"Flags","description":"flag: Vatican City","unicode_version":"6.0"},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"],"tags":[],"category":"Flags","description":"flag: St. Vincent & Grenadines","unicode_version":"6.0"},{"emoji":"🇻🇪","aliases":["venezuela"],"tags":[],"category":"Flags","description":"flag: Venezuela","unicode_version":"6.0"},{"emoji":"🇻🇬","aliases":["british_virgin_islands"],"tags":[],"category":"Flags","description":"flag: British Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇮","aliases":["us_virgin_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇳","aliases":["vietnam"],"tags":[],"category":"Flags","description":"flag: Vietnam","unicode_version":"6.0"},{"emoji":"🇻🇺","aliases":["vanuatu"],"tags":[],"category":"Flags","description":"flag: Vanuatu","unicode_version":"6.0"},{"emoji":"🇼🇫","aliases":["wallis_futuna"],"tags":[],"category":"Flags","description":"flag: Wallis & Futuna","unicode_version":"6.0"},{"emoji":"🇼🇸","aliases":["samoa"],"tags":[],"category":"Flags","description":"flag: Samoa","unicode_version":"6.0"},{"emoji":"🇽🇰","aliases":["kosovo"],"tags":[],"category":"Flags","description":"flag: Kosovo","unicode_version":"6.0"},{"emoji":"🇾🇪","aliases":["yemen"],"tags":[],"category":"Flags","description":"flag: Yemen","unicode_version":"6.0"},{"emoji":"🇾🇹","aliases":["mayotte"],"tags":[],"category":"Flags","description":"flag: Mayotte","unicode_version":"6.0"},{"emoji":"🇿🇦","aliases":["south_africa"],"tags":[],"category":"Flags","description":"flag: South Africa","unicode_version":"6.0"},{"emoji":"🇿🇲","aliases":["zambia"],"tags":[],"category":"Flags","description":"flag: Zambia","unicode_version":"6.0"},{"emoji":"🇿🇼","aliases":["zimbabwe"],"tags":[],"category":"Flags","description":"flag: Zimbabwe","unicode_version":"6.0"},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"],"tags":[],"category":"Flags","description":"flag: England","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"],"tags":[],"category":"Flags","description":"flag: Scotland","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"],"tags":[],"category":"Flags","description":"flag: Wales","unicode_version":"11.0"}] +export const rawEmojis = [ + { + emoji: "😀", + aliases: ["grinning"], + tags: ["smile", "happy"], + category: "Smileys & Emotion", + description: "grinning face", + unicode_version: "6.1", + }, + { + emoji: "😃", + aliases: ["smiley"], + tags: ["happy", "joy", "haha"], + category: "Smileys & Emotion", + description: "grinning face with big eyes", + unicode_version: "6.0", + }, + { + emoji: "😄", + aliases: ["smile"], + tags: ["happy", "joy", "laugh", "pleased"], + category: "Smileys & Emotion", + description: "grinning face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😁", + aliases: ["grin"], + tags: [], + category: "Smileys & Emotion", + description: "beaming face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😆", + aliases: ["laughing", "satisfied"], + tags: ["happy", "haha"], + category: "Smileys & Emotion", + description: "grinning squinting face", + unicode_version: "6.0", + }, + { + emoji: "😅", + aliases: ["sweat_smile"], + tags: ["hot"], + category: "Smileys & Emotion", + description: "grinning face with sweat", + unicode_version: "6.0", + }, + { + emoji: "🤣", + aliases: ["rofl"], + tags: ["lol", "laughing"], + category: "Smileys & Emotion", + description: "rolling on the floor laughing", + unicode_version: "9.0", + }, + { + emoji: "😂", + aliases: ["joy"], + tags: ["tears"], + category: "Smileys & Emotion", + description: "face with tears of joy", + unicode_version: "6.0", + }, + { + emoji: "🙂", + aliases: ["slightly_smiling_face"], + tags: [], + category: "Smileys & Emotion", + description: "slightly smiling face", + unicode_version: "7.0", + }, + { + emoji: "🙃", + aliases: ["upside_down_face"], + tags: [], + category: "Smileys & Emotion", + description: "upside-down face", + unicode_version: "8.0", + }, + { + emoji: "😉", + aliases: ["wink"], + tags: ["flirt"], + category: "Smileys & Emotion", + description: "winking face", + unicode_version: "6.0", + }, + { + emoji: "😊", + aliases: ["blush"], + tags: ["proud"], + category: "Smileys & Emotion", + description: "smiling face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😇", + aliases: ["innocent"], + tags: ["angel"], + category: "Smileys & Emotion", + description: "smiling face with halo", + unicode_version: "6.0", + }, + { + emoji: "🥰", + aliases: ["smiling_face_with_three_hearts"], + tags: ["love"], + category: "Smileys & Emotion", + description: "smiling face with hearts", + unicode_version: "11.0", + }, + { + emoji: "😍", + aliases: ["heart_eyes"], + tags: ["love", "crush"], + category: "Smileys & Emotion", + description: "smiling face with heart-eyes", + unicode_version: "6.0", + }, + { + emoji: "🤩", + aliases: ["star_struck"], + tags: ["eyes"], + category: "Smileys & Emotion", + description: "star-struck", + unicode_version: "11.0", + }, + { + emoji: "😘", + aliases: ["kissing_heart"], + tags: ["flirt"], + category: "Smileys & Emotion", + description: "face blowing a kiss", + unicode_version: "6.0", + }, + { + emoji: "😗", + aliases: ["kissing"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face", + unicode_version: "6.1", + }, + { + emoji: "☺️", + aliases: ["relaxed"], + tags: ["blush", "pleased"], + category: "Smileys & Emotion", + description: "smiling face", + unicode_version: "", + }, + { + emoji: "😚", + aliases: ["kissing_closed_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face with closed eyes", + unicode_version: "6.0", + }, + { + emoji: "😙", + aliases: ["kissing_smiling_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face with smiling eyes", + unicode_version: "6.1", + }, + { + emoji: "🥲", + aliases: ["smiling_face_with_tear"], + tags: [], + category: "Smileys & Emotion", + description: "smiling face with tear", + unicode_version: "13.0", + }, + { + emoji: "😋", + aliases: ["yum"], + tags: ["tongue", "lick"], + category: "Smileys & Emotion", + description: "face savoring food", + unicode_version: "6.0", + }, + { + emoji: "😛", + aliases: ["stuck_out_tongue"], + tags: [], + category: "Smileys & Emotion", + description: "face with tongue", + unicode_version: "6.1", + }, + { + emoji: "😜", + aliases: ["stuck_out_tongue_winking_eye"], + tags: ["prank", "silly"], + category: "Smileys & Emotion", + description: "winking face with tongue", + unicode_version: "6.0", + }, + { + emoji: "🤪", + aliases: ["zany_face"], + tags: ["goofy", "wacky"], + category: "Smileys & Emotion", + description: "zany face", + unicode_version: "11.0", + }, + { + emoji: "😝", + aliases: ["stuck_out_tongue_closed_eyes"], + tags: ["prank"], + category: "Smileys & Emotion", + description: "squinting face with tongue", + unicode_version: "6.0", + }, + { + emoji: "🤑", + aliases: ["money_mouth_face"], + tags: ["rich"], + category: "Smileys & Emotion", + description: "money-mouth face", + unicode_version: "8.0", + }, + { + emoji: "🤗", + aliases: ["hugs"], + tags: [], + category: "Smileys & Emotion", + description: "hugging face", + unicode_version: "8.0", + }, + { + emoji: "🤭", + aliases: ["hand_over_mouth"], + tags: ["quiet", "whoops"], + category: "Smileys & Emotion", + description: "face with hand over mouth", + unicode_version: "11.0", + }, + { + emoji: "🤫", + aliases: ["shushing_face"], + tags: ["silence", "quiet"], + category: "Smileys & Emotion", + description: "shushing face", + unicode_version: "11.0", + }, + { + emoji: "🤔", + aliases: ["thinking"], + tags: [], + category: "Smileys & Emotion", + description: "thinking face", + unicode_version: "8.0", + }, + { + emoji: "🤐", + aliases: ["zipper_mouth_face"], + tags: ["silence", "hush"], + category: "Smileys & Emotion", + description: "zipper-mouth face", + unicode_version: "8.0", + }, + { + emoji: "🤨", + aliases: ["raised_eyebrow"], + tags: ["suspicious"], + category: "Smileys & Emotion", + description: "face with raised eyebrow", + unicode_version: "11.0", + }, + { + emoji: "😐", + aliases: ["neutral_face"], + tags: ["meh"], + category: "Smileys & Emotion", + description: "neutral face", + unicode_version: "6.0", + }, + { + emoji: "😑", + aliases: ["expressionless"], + tags: [], + category: "Smileys & Emotion", + description: "expressionless face", + unicode_version: "6.1", + }, + { + emoji: "😶", + aliases: ["no_mouth"], + tags: ["mute", "silence"], + category: "Smileys & Emotion", + description: "face without mouth", + unicode_version: "6.0", + }, + { + emoji: "😶‍🌫️", + aliases: ["face_in_clouds"], + tags: [], + category: "Smileys & Emotion", + description: "face in clouds", + unicode_version: "13.1", + }, + { + emoji: "😏", + aliases: ["smirk"], + tags: ["smug"], + category: "Smileys & Emotion", + description: "smirking face", + unicode_version: "6.0", + }, + { + emoji: "😒", + aliases: ["unamused"], + tags: ["meh"], + category: "Smileys & Emotion", + description: "unamused face", + unicode_version: "6.0", + }, + { + emoji: "🙄", + aliases: ["roll_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "face with rolling eyes", + unicode_version: "8.0", + }, + { + emoji: "😬", + aliases: ["grimacing"], + tags: [], + category: "Smileys & Emotion", + description: "grimacing face", + unicode_version: "6.1", + }, + { + emoji: "😮‍💨", + aliases: ["face_exhaling"], + tags: [], + category: "Smileys & Emotion", + description: "face exhaling", + unicode_version: "13.1", + }, + { + emoji: "🤥", + aliases: ["lying_face"], + tags: ["liar"], + category: "Smileys & Emotion", + description: "lying face", + unicode_version: "9.0", + }, + { + emoji: "😌", + aliases: ["relieved"], + tags: ["whew"], + category: "Smileys & Emotion", + description: "relieved face", + unicode_version: "6.0", + }, + { + emoji: "😔", + aliases: ["pensive"], + tags: [], + category: "Smileys & Emotion", + description: "pensive face", + unicode_version: "6.0", + }, + { + emoji: "😪", + aliases: ["sleepy"], + tags: ["tired"], + category: "Smileys & Emotion", + description: "sleepy face", + unicode_version: "6.0", + }, + { + emoji: "🤤", + aliases: ["drooling_face"], + tags: [], + category: "Smileys & Emotion", + description: "drooling face", + unicode_version: "9.0", + }, + { + emoji: "😴", + aliases: ["sleeping"], + tags: ["zzz"], + category: "Smileys & Emotion", + description: "sleeping face", + unicode_version: "6.1", + }, + { + emoji: "😷", + aliases: ["mask"], + tags: ["sick", "ill"], + category: "Smileys & Emotion", + description: "face with medical mask", + unicode_version: "6.0", + }, + { + emoji: "🤒", + aliases: ["face_with_thermometer"], + tags: ["sick"], + category: "Smileys & Emotion", + description: "face with thermometer", + unicode_version: "8.0", + }, + { + emoji: "🤕", + aliases: ["face_with_head_bandage"], + tags: ["hurt"], + category: "Smileys & Emotion", + description: "face with head-bandage", + unicode_version: "8.0", + }, + { + emoji: "🤢", + aliases: ["nauseated_face"], + tags: ["sick", "barf", "disgusted"], + category: "Smileys & Emotion", + description: "nauseated face", + unicode_version: "9.0", + }, + { + emoji: "🤮", + aliases: ["vomiting_face"], + tags: ["barf", "sick"], + category: "Smileys & Emotion", + description: "face vomiting", + unicode_version: "11.0", + }, + { + emoji: "🤧", + aliases: ["sneezing_face"], + tags: ["achoo", "sick"], + category: "Smileys & Emotion", + description: "sneezing face", + unicode_version: "9.0", + }, + { + emoji: "🥵", + aliases: ["hot_face"], + tags: ["heat", "sweating"], + category: "Smileys & Emotion", + description: "hot face", + unicode_version: "11.0", + }, + { + emoji: "🥶", + aliases: ["cold_face"], + tags: ["freezing", "ice"], + category: "Smileys & Emotion", + description: "cold face", + unicode_version: "11.0", + }, + { + emoji: "🥴", + aliases: ["woozy_face"], + tags: ["groggy"], + category: "Smileys & Emotion", + description: "woozy face", + unicode_version: "11.0", + }, + { + emoji: "😵", + aliases: ["dizzy_face"], + tags: [], + category: "Smileys & Emotion", + description: "knocked-out face", + unicode_version: "6.0", + }, + { + emoji: "😵‍💫", + aliases: ["face_with_spiral_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "face with spiral eyes", + unicode_version: "13.1", + }, + { + emoji: "🤯", + aliases: ["exploding_head"], + tags: ["mind", "blown"], + category: "Smileys & Emotion", + description: "exploding head", + unicode_version: "11.0", + }, + { + emoji: "🤠", + aliases: ["cowboy_hat_face"], + tags: [], + category: "Smileys & Emotion", + description: "cowboy hat face", + unicode_version: "9.0", + }, + { + emoji: "🥳", + aliases: ["partying_face"], + tags: ["celebration", "birthday"], + category: "Smileys & Emotion", + description: "partying face", + unicode_version: "11.0", + }, + { + emoji: "🥸", + aliases: ["disguised_face"], + tags: [], + category: "Smileys & Emotion", + description: "disguised face", + unicode_version: "13.0", + }, + { + emoji: "😎", + aliases: ["sunglasses"], + tags: ["cool"], + category: "Smileys & Emotion", + description: "smiling face with sunglasses", + unicode_version: "6.0", + }, + { + emoji: "🤓", + aliases: ["nerd_face"], + tags: ["geek", "glasses"], + category: "Smileys & Emotion", + description: "nerd face", + unicode_version: "8.0", + }, + { + emoji: "🧐", + aliases: ["monocle_face"], + tags: [], + category: "Smileys & Emotion", + description: "face with monocle", + unicode_version: "11.0", + }, + { + emoji: "😕", + aliases: ["confused"], + tags: [], + category: "Smileys & Emotion", + description: "confused face", + unicode_version: "6.1", + }, + { + emoji: "😟", + aliases: ["worried"], + tags: ["nervous"], + category: "Smileys & Emotion", + description: "worried face", + unicode_version: "6.1", + }, + { + emoji: "🙁", + aliases: ["slightly_frowning_face"], + tags: [], + category: "Smileys & Emotion", + description: "slightly frowning face", + unicode_version: "7.0", + }, + { + emoji: "☹️", + aliases: ["frowning_face"], + tags: [], + category: "Smileys & Emotion", + description: "frowning face", + unicode_version: "", + }, + { + emoji: "😮", + aliases: ["open_mouth"], + tags: ["surprise", "impressed", "wow"], + category: "Smileys & Emotion", + description: "face with open mouth", + unicode_version: "6.1", + }, + { + emoji: "😯", + aliases: ["hushed"], + tags: ["silence", "speechless"], + category: "Smileys & Emotion", + description: "hushed face", + unicode_version: "6.1", + }, + { + emoji: "😲", + aliases: ["astonished"], + tags: ["amazed", "gasp"], + category: "Smileys & Emotion", + description: "astonished face", + unicode_version: "6.0", + }, + { + emoji: "😳", + aliases: ["flushed"], + tags: [], + category: "Smileys & Emotion", + description: "flushed face", + unicode_version: "6.0", + }, + { + emoji: "🥺", + aliases: ["pleading_face"], + tags: ["puppy", "eyes"], + category: "Smileys & Emotion", + description: "pleading face", + unicode_version: "11.0", + }, + { + emoji: "😦", + aliases: ["frowning"], + tags: [], + category: "Smileys & Emotion", + description: "frowning face with open mouth", + unicode_version: "6.1", + }, + { + emoji: "😧", + aliases: ["anguished"], + tags: ["stunned"], + category: "Smileys & Emotion", + description: "anguished face", + unicode_version: "6.1", + }, + { + emoji: "😨", + aliases: ["fearful"], + tags: ["scared", "shocked", "oops"], + category: "Smileys & Emotion", + description: "fearful face", + unicode_version: "6.0", + }, + { + emoji: "😰", + aliases: ["cold_sweat"], + tags: ["nervous"], + category: "Smileys & Emotion", + description: "anxious face with sweat", + unicode_version: "6.0", + }, + { + emoji: "😥", + aliases: ["disappointed_relieved"], + tags: ["phew", "sweat", "nervous"], + category: "Smileys & Emotion", + description: "sad but relieved face", + unicode_version: "6.0", + }, + { + emoji: "😢", + aliases: ["cry"], + tags: ["sad", "tear"], + category: "Smileys & Emotion", + description: "crying face", + unicode_version: "6.0", + }, + { + emoji: "😭", + aliases: ["sob"], + tags: ["sad", "cry", "bawling"], + category: "Smileys & Emotion", + description: "loudly crying face", + unicode_version: "6.0", + }, + { + emoji: "😱", + aliases: ["scream"], + tags: ["horror", "shocked"], + category: "Smileys & Emotion", + description: "face screaming in fear", + unicode_version: "6.0", + }, + { + emoji: "😖", + aliases: ["confounded"], + tags: [], + category: "Smileys & Emotion", + description: "confounded face", + unicode_version: "6.0", + }, + { + emoji: "😣", + aliases: ["persevere"], + tags: ["struggling"], + category: "Smileys & Emotion", + description: "persevering face", + unicode_version: "6.0", + }, + { + emoji: "😞", + aliases: ["disappointed"], + tags: ["sad"], + category: "Smileys & Emotion", + description: "disappointed face", + unicode_version: "6.0", + }, + { + emoji: "😓", + aliases: ["sweat"], + tags: [], + category: "Smileys & Emotion", + description: "downcast face with sweat", + unicode_version: "6.0", + }, + { + emoji: "😩", + aliases: ["weary"], + tags: ["tired"], + category: "Smileys & Emotion", + description: "weary face", + unicode_version: "6.0", + }, + { + emoji: "😫", + aliases: ["tired_face"], + tags: ["upset", "whine"], + category: "Smileys & Emotion", + description: "tired face", + unicode_version: "6.0", + }, + { + emoji: "🥱", + aliases: ["yawning_face"], + tags: [], + category: "Smileys & Emotion", + description: "yawning face", + unicode_version: "12.0", + }, + { + emoji: "😤", + aliases: ["triumph"], + tags: ["smug"], + category: "Smileys & Emotion", + description: "face with steam from nose", + unicode_version: "6.0", + }, + { + emoji: "😡", + aliases: ["rage", "pout"], + tags: ["angry"], + category: "Smileys & Emotion", + description: "pouting face", + unicode_version: "6.0", + }, + { + emoji: "😠", + aliases: ["angry"], + tags: ["mad", "annoyed"], + category: "Smileys & Emotion", + description: "angry face", + unicode_version: "6.0", + }, + { + emoji: "🤬", + aliases: ["cursing_face"], + tags: ["foul"], + category: "Smileys & Emotion", + description: "face with symbols on mouth", + unicode_version: "11.0", + }, + { + emoji: "😈", + aliases: ["smiling_imp"], + tags: ["devil", "evil", "horns"], + category: "Smileys & Emotion", + description: "smiling face with horns", + unicode_version: "6.0", + }, + { + emoji: "👿", + aliases: ["imp"], + tags: ["angry", "devil", "evil", "horns"], + category: "Smileys & Emotion", + description: "angry face with horns", + unicode_version: "6.0", + }, + { + emoji: "💀", + aliases: ["skull"], + tags: ["dead", "danger", "poison"], + category: "Smileys & Emotion", + description: "skull", + unicode_version: "6.0", + }, + { + emoji: "☠️", + aliases: ["skull_and_crossbones"], + tags: ["danger", "pirate"], + category: "Smileys & Emotion", + description: "skull and crossbones", + unicode_version: "", + }, + { + emoji: "💩", + aliases: ["hankey", "poop", "shit"], + tags: ["crap"], + category: "Smileys & Emotion", + description: "pile of poo", + unicode_version: "6.0", + }, + { + emoji: "🤡", + aliases: ["clown_face"], + tags: [], + category: "Smileys & Emotion", + description: "clown face", + unicode_version: "9.0", + }, + { + emoji: "👹", + aliases: ["japanese_ogre"], + tags: ["monster"], + category: "Smileys & Emotion", + description: "ogre", + unicode_version: "6.0", + }, + { + emoji: "👺", + aliases: ["japanese_goblin"], + tags: [], + category: "Smileys & Emotion", + description: "goblin", + unicode_version: "6.0", + }, + { + emoji: "👻", + aliases: ["ghost"], + tags: ["halloween"], + category: "Smileys & Emotion", + description: "ghost", + unicode_version: "6.0", + }, + { + emoji: "👽", + aliases: ["alien"], + tags: ["ufo"], + category: "Smileys & Emotion", + description: "alien", + unicode_version: "6.0", + }, + { + emoji: "👾", + aliases: ["space_invader"], + tags: ["game", "retro"], + category: "Smileys & Emotion", + description: "alien monster", + unicode_version: "6.0", + }, + { + emoji: "🤖", + aliases: ["robot"], + tags: [], + category: "Smileys & Emotion", + description: "robot", + unicode_version: "8.0", + }, + { + emoji: "😺", + aliases: ["smiley_cat"], + tags: [], + category: "Smileys & Emotion", + description: "grinning cat", + unicode_version: "6.0", + }, + { + emoji: "😸", + aliases: ["smile_cat"], + tags: [], + category: "Smileys & Emotion", + description: "grinning cat with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😹", + aliases: ["joy_cat"], + tags: [], + category: "Smileys & Emotion", + description: "cat with tears of joy", + unicode_version: "6.0", + }, + { + emoji: "😻", + aliases: ["heart_eyes_cat"], + tags: [], + category: "Smileys & Emotion", + description: "smiling cat with heart-eyes", + unicode_version: "6.0", + }, + { + emoji: "😼", + aliases: ["smirk_cat"], + tags: [], + category: "Smileys & Emotion", + description: "cat with wry smile", + unicode_version: "6.0", + }, + { + emoji: "😽", + aliases: ["kissing_cat"], + tags: [], + category: "Smileys & Emotion", + description: "kissing cat", + unicode_version: "6.0", + }, + { + emoji: "🙀", + aliases: ["scream_cat"], + tags: ["horror"], + category: "Smileys & Emotion", + description: "weary cat", + unicode_version: "6.0", + }, + { + emoji: "😿", + aliases: ["crying_cat_face"], + tags: ["sad", "tear"], + category: "Smileys & Emotion", + description: "crying cat", + unicode_version: "6.0", + }, + { + emoji: "😾", + aliases: ["pouting_cat"], + tags: [], + category: "Smileys & Emotion", + description: "pouting cat", + unicode_version: "6.0", + }, + { + emoji: "🙈", + aliases: ["see_no_evil"], + tags: ["monkey", "blind", "ignore"], + category: "Smileys & Emotion", + description: "see-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "🙉", + aliases: ["hear_no_evil"], + tags: ["monkey", "deaf"], + category: "Smileys & Emotion", + description: "hear-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "🙊", + aliases: ["speak_no_evil"], + tags: ["monkey", "mute", "hush"], + category: "Smileys & Emotion", + description: "speak-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "💋", + aliases: ["kiss"], + tags: ["lipstick"], + category: "Smileys & Emotion", + description: "kiss mark", + unicode_version: "6.0", + }, + { + emoji: "💌", + aliases: ["love_letter"], + tags: ["email", "envelope"], + category: "Smileys & Emotion", + description: "love letter", + unicode_version: "6.0", + }, + { + emoji: "💘", + aliases: ["cupid"], + tags: ["love", "heart"], + category: "Smileys & Emotion", + description: "heart with arrow", + unicode_version: "6.0", + }, + { + emoji: "💝", + aliases: ["gift_heart"], + tags: ["chocolates"], + category: "Smileys & Emotion", + description: "heart with ribbon", + unicode_version: "6.0", + }, + { + emoji: "💖", + aliases: ["sparkling_heart"], + tags: [], + category: "Smileys & Emotion", + description: "sparkling heart", + unicode_version: "6.0", + }, + { + emoji: "💗", + aliases: ["heartpulse"], + tags: [], + category: "Smileys & Emotion", + description: "growing heart", + unicode_version: "6.0", + }, + { + emoji: "💓", + aliases: ["heartbeat"], + tags: [], + category: "Smileys & Emotion", + description: "beating heart", + unicode_version: "6.0", + }, + { + emoji: "💞", + aliases: ["revolving_hearts"], + tags: [], + category: "Smileys & Emotion", + description: "revolving hearts", + unicode_version: "6.0", + }, + { + emoji: "💕", + aliases: ["two_hearts"], + tags: [], + category: "Smileys & Emotion", + description: "two hearts", + unicode_version: "6.0", + }, + { + emoji: "💟", + aliases: ["heart_decoration"], + tags: [], + category: "Smileys & Emotion", + description: "heart decoration", + unicode_version: "6.0", + }, + { + emoji: "❣️", + aliases: ["heavy_heart_exclamation"], + tags: [], + category: "Smileys & Emotion", + description: "heart exclamation", + unicode_version: "", + }, + { + emoji: "💔", + aliases: ["broken_heart"], + tags: [], + category: "Smileys & Emotion", + description: "broken heart", + unicode_version: "6.0", + }, + { + emoji: "❤️‍🔥", + aliases: ["heart_on_fire"], + tags: [], + category: "Smileys & Emotion", + description: "heart on fire", + unicode_version: "13.1", + }, + { + emoji: "❤️‍🩹", + aliases: ["mending_heart"], + tags: [], + category: "Smileys & Emotion", + description: "mending heart", + unicode_version: "13.1", + }, + { + emoji: "❤️", + aliases: ["heart"], + tags: ["love"], + category: "Smileys & Emotion", + description: "red heart", + unicode_version: "", + }, + { + emoji: "🧡", + aliases: ["orange_heart"], + tags: [], + category: "Smileys & Emotion", + description: "orange heart", + unicode_version: "11.0", + }, + { + emoji: "💛", + aliases: ["yellow_heart"], + tags: [], + category: "Smileys & Emotion", + description: "yellow heart", + unicode_version: "6.0", + }, + { + emoji: "💚", + aliases: ["green_heart"], + tags: [], + category: "Smileys & Emotion", + description: "green heart", + unicode_version: "6.0", + }, + { + emoji: "💙", + aliases: ["blue_heart"], + tags: [], + category: "Smileys & Emotion", + description: "blue heart", + unicode_version: "6.0", + }, + { + emoji: "💜", + aliases: ["purple_heart"], + tags: [], + category: "Smileys & Emotion", + description: "purple heart", + unicode_version: "6.0", + }, + { + emoji: "🤎", + aliases: ["brown_heart"], + tags: [], + category: "Smileys & Emotion", + description: "brown heart", + unicode_version: "12.0", + }, + { + emoji: "🖤", + aliases: ["black_heart"], + tags: [], + category: "Smileys & Emotion", + description: "black heart", + unicode_version: "9.0", + }, + { + emoji: "🤍", + aliases: ["white_heart"], + tags: [], + category: "Smileys & Emotion", + description: "white heart", + unicode_version: "12.0", + }, + { + emoji: "💯", + aliases: ["100"], + tags: ["score", "perfect"], + category: "Smileys & Emotion", + description: "hundred points", + unicode_version: "6.0", + }, + { + emoji: "💢", + aliases: ["anger"], + tags: ["angry"], + category: "Smileys & Emotion", + description: "anger symbol", + unicode_version: "6.0", + }, + { + emoji: "💥", + aliases: ["boom", "collision"], + tags: ["explode"], + category: "Smileys & Emotion", + description: "collision", + unicode_version: "6.0", + }, + { + emoji: "💫", + aliases: ["dizzy"], + tags: ["star"], + category: "Smileys & Emotion", + description: "dizzy", + unicode_version: "6.0", + }, + { + emoji: "💦", + aliases: ["sweat_drops"], + tags: ["water", "workout"], + category: "Smileys & Emotion", + description: "sweat droplets", + unicode_version: "6.0", + }, + { + emoji: "💨", + aliases: ["dash"], + tags: ["wind", "blow", "fast"], + category: "Smileys & Emotion", + description: "dashing away", + unicode_version: "6.0", + }, + { + emoji: "🕳️", + aliases: ["hole"], + tags: [], + category: "Smileys & Emotion", + description: "hole", + unicode_version: "7.0", + }, + { + emoji: "💣", + aliases: ["bomb"], + tags: ["boom"], + category: "Smileys & Emotion", + description: "bomb", + unicode_version: "6.0", + }, + { + emoji: "💬", + aliases: ["speech_balloon"], + tags: ["comment"], + category: "Smileys & Emotion", + description: "speech balloon", + unicode_version: "6.0", + }, + { + emoji: "👁️‍🗨️", + aliases: ["eye_speech_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "eye in speech bubble", + unicode_version: "11.0", + }, + { + emoji: "🗨️", + aliases: ["left_speech_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "left speech bubble", + unicode_version: "11.0", + }, + { + emoji: "🗯️", + aliases: ["right_anger_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "right anger bubble", + unicode_version: "7.0", + }, + { + emoji: "💭", + aliases: ["thought_balloon"], + tags: ["thinking"], + category: "Smileys & Emotion", + description: "thought balloon", + unicode_version: "6.0", + }, + { + emoji: "💤", + aliases: ["zzz"], + tags: ["sleeping"], + category: "Smileys & Emotion", + description: "zzz", + unicode_version: "6.0", + }, + { + emoji: "👋", + aliases: ["wave"], + tags: ["goodbye"], + category: "People & Body", + description: "waving hand", + unicode_version: "6.0", + }, + { + emoji: "🤚", + aliases: ["raised_back_of_hand"], + tags: [], + category: "People & Body", + description: "raised back of hand", + unicode_version: "9.0", + }, + { + emoji: "🖐️", + aliases: ["raised_hand_with_fingers_splayed"], + tags: [], + category: "People & Body", + description: "hand with fingers splayed", + unicode_version: "7.0", + }, + { + emoji: "✋", + aliases: ["hand", "raised_hand"], + tags: ["highfive", "stop"], + category: "People & Body", + description: "raised hand", + unicode_version: "6.0", + }, + { + emoji: "🖖", + aliases: ["vulcan_salute"], + tags: ["prosper", "spock"], + category: "People & Body", + description: "vulcan salute", + unicode_version: "7.0", + }, + { + emoji: "👌", + aliases: ["ok_hand"], + tags: [], + category: "People & Body", + description: "OK hand", + unicode_version: "6.0", + }, + { + emoji: "🤌", + aliases: ["pinched_fingers"], + tags: [], + category: "People & Body", + description: "pinched fingers", + unicode_version: "13.0", + }, + { + emoji: "🤏", + aliases: ["pinching_hand"], + tags: [], + category: "People & Body", + description: "pinching hand", + unicode_version: "12.0", + }, + { + emoji: "✌️", + aliases: ["v"], + tags: ["victory", "peace"], + category: "People & Body", + description: "victory hand", + unicode_version: "", + }, + { + emoji: "🤞", + aliases: ["crossed_fingers"], + tags: ["luck", "hopeful"], + category: "People & Body", + description: "crossed fingers", + unicode_version: "9.0", + }, + { + emoji: "🤟", + aliases: ["love_you_gesture"], + tags: [], + category: "People & Body", + description: "love-you gesture", + unicode_version: "11.0", + }, + { + emoji: "🤘", + aliases: ["metal"], + tags: [], + category: "People & Body", + description: "sign of the horns", + unicode_version: "8.0", + }, + { + emoji: "🤙", + aliases: ["call_me_hand"], + tags: [], + category: "People & Body", + description: "call me hand", + unicode_version: "9.0", + }, + { + emoji: "👈", + aliases: ["point_left"], + tags: [], + category: "People & Body", + description: "backhand index pointing left", + unicode_version: "6.0", + }, + { + emoji: "👉", + aliases: ["point_right"], + tags: [], + category: "People & Body", + description: "backhand index pointing right", + unicode_version: "6.0", + }, + { + emoji: "👆", + aliases: ["point_up_2"], + tags: [], + category: "People & Body", + description: "backhand index pointing up", + unicode_version: "6.0", + }, + { + emoji: "🖕", + aliases: ["middle_finger", "fu"], + tags: [], + category: "People & Body", + description: "middle finger", + unicode_version: "7.0", + }, + { + emoji: "👇", + aliases: ["point_down"], + tags: [], + category: "People & Body", + description: "backhand index pointing down", + unicode_version: "6.0", + }, + { + emoji: "☝️", + aliases: ["point_up"], + tags: [], + category: "People & Body", + description: "index pointing up", + unicode_version: "", + }, + { + emoji: "👍", + aliases: ["+1", "thumbsup"], + tags: ["approve", "ok"], + category: "People & Body", + description: "thumbs up", + unicode_version: "6.0", + }, + { + emoji: "👎", + aliases: ["-1", "thumbsdown"], + tags: ["disapprove", "bury"], + category: "People & Body", + description: "thumbs down", + unicode_version: "6.0", + }, + { + emoji: "✊", + aliases: ["fist_raised", "fist"], + tags: ["power"], + category: "People & Body", + description: "raised fist", + unicode_version: "6.0", + }, + { + emoji: "👊", + aliases: ["fist_oncoming", "facepunch", "punch"], + tags: ["attack"], + category: "People & Body", + description: "oncoming fist", + unicode_version: "6.0", + }, + { + emoji: "🤛", + aliases: ["fist_left"], + tags: [], + category: "People & Body", + description: "left-facing fist", + unicode_version: "9.0", + }, + { + emoji: "🤜", + aliases: ["fist_right"], + tags: [], + category: "People & Body", + description: "right-facing fist", + unicode_version: "9.0", + }, + { + emoji: "👏", + aliases: ["clap"], + tags: ["praise", "applause"], + category: "People & Body", + description: "clapping hands", + unicode_version: "6.0", + }, + { + emoji: "🙌", + aliases: ["raised_hands"], + tags: ["hooray"], + category: "People & Body", + description: "raising hands", + unicode_version: "6.0", + }, + { + emoji: "👐", + aliases: ["open_hands"], + tags: [], + category: "People & Body", + description: "open hands", + unicode_version: "6.0", + }, + { + emoji: "🤲", + aliases: ["palms_up_together"], + tags: [], + category: "People & Body", + description: "palms up together", + unicode_version: "11.0", + }, + { + emoji: "🤝", + aliases: ["handshake"], + tags: ["deal"], + category: "People & Body", + description: "handshake", + unicode_version: "9.0", + }, + { + emoji: "🙏", + aliases: ["pray"], + tags: ["please", "hope", "wish"], + category: "People & Body", + description: "folded hands", + unicode_version: "6.0", + }, + { + emoji: "✍️", + aliases: ["writing_hand"], + tags: [], + category: "People & Body", + description: "writing hand", + unicode_version: "", + }, + { + emoji: "💅", + aliases: ["nail_care"], + tags: ["beauty", "manicure"], + category: "People & Body", + description: "nail polish", + unicode_version: "6.0", + }, + { + emoji: "🤳", + aliases: ["selfie"], + tags: [], + category: "People & Body", + description: "selfie", + unicode_version: "9.0", + }, + { + emoji: "💪", + aliases: ["muscle"], + tags: ["flex", "bicep", "strong", "workout"], + category: "People & Body", + description: "flexed biceps", + unicode_version: "6.0", + }, + { + emoji: "🦾", + aliases: ["mechanical_arm"], + tags: [], + category: "People & Body", + description: "mechanical arm", + unicode_version: "12.0", + }, + { + emoji: "🦿", + aliases: ["mechanical_leg"], + tags: [], + category: "People & Body", + description: "mechanical leg", + unicode_version: "12.0", + }, + { + emoji: "🦵", + aliases: ["leg"], + tags: [], + category: "People & Body", + description: "leg", + unicode_version: "11.0", + }, + { + emoji: "🦶", + aliases: ["foot"], + tags: [], + category: "People & Body", + description: "foot", + unicode_version: "11.0", + }, + { + emoji: "👂", + aliases: ["ear"], + tags: ["hear", "sound", "listen"], + category: "People & Body", + description: "ear", + unicode_version: "6.0", + }, + { + emoji: "🦻", + aliases: ["ear_with_hearing_aid"], + tags: [], + category: "People & Body", + description: "ear with hearing aid", + unicode_version: "12.0", + }, + { + emoji: "👃", + aliases: ["nose"], + tags: ["smell"], + category: "People & Body", + description: "nose", + unicode_version: "6.0", + }, + { + emoji: "🧠", + aliases: ["brain"], + tags: [], + category: "People & Body", + description: "brain", + unicode_version: "11.0", + }, + { + emoji: "🫀", + aliases: ["anatomical_heart"], + tags: [], + category: "People & Body", + description: "anatomical heart", + unicode_version: "13.0", + }, + { + emoji: "🫁", + aliases: ["lungs"], + tags: [], + category: "People & Body", + description: "lungs", + unicode_version: "13.0", + }, + { + emoji: "🦷", + aliases: ["tooth"], + tags: [], + category: "People & Body", + description: "tooth", + unicode_version: "11.0", + }, + { + emoji: "🦴", + aliases: ["bone"], + tags: [], + category: "People & Body", + description: "bone", + unicode_version: "11.0", + }, + { + emoji: "👀", + aliases: ["eyes"], + tags: ["look", "see", "watch"], + category: "People & Body", + description: "eyes", + unicode_version: "6.0", + }, + { + emoji: "👁️", + aliases: ["eye"], + tags: [], + category: "People & Body", + description: "eye", + unicode_version: "7.0", + }, + { + emoji: "👅", + aliases: ["tongue"], + tags: ["taste"], + category: "People & Body", + description: "tongue", + unicode_version: "6.0", + }, + { + emoji: "👄", + aliases: ["lips"], + tags: ["kiss"], + category: "People & Body", + description: "mouth", + unicode_version: "6.0", + }, + { + emoji: "👶", + aliases: ["baby"], + tags: ["child", "newborn"], + category: "People & Body", + description: "baby", + unicode_version: "6.0", + }, + { + emoji: "🧒", + aliases: ["child"], + tags: [], + category: "People & Body", + description: "child", + unicode_version: "11.0", + }, + { + emoji: "👦", + aliases: ["boy"], + tags: ["child"], + category: "People & Body", + description: "boy", + unicode_version: "6.0", + }, + { + emoji: "👧", + aliases: ["girl"], + tags: ["child"], + category: "People & Body", + description: "girl", + unicode_version: "6.0", + }, + { + emoji: "🧑", + aliases: ["adult"], + tags: [], + category: "People & Body", + description: "person", + unicode_version: "11.0", + }, + { + emoji: "👱", + aliases: ["blond_haired_person"], + tags: [], + category: "People & Body", + description: "person: blond hair", + unicode_version: "6.0", + }, + { + emoji: "👨", + aliases: ["man"], + tags: ["mustache", "father", "dad"], + category: "People & Body", + description: "man", + unicode_version: "6.0", + }, + { + emoji: "🧔", + aliases: ["bearded_person"], + tags: [], + category: "People & Body", + description: "person: beard", + unicode_version: "11.0", + }, + { + emoji: "🧔‍♂️", + aliases: ["man_beard"], + tags: [], + category: "People & Body", + description: "man: beard", + unicode_version: "13.1", + }, + { + emoji: "🧔‍♀️", + aliases: ["woman_beard"], + tags: [], + category: "People & Body", + description: "woman: beard", + unicode_version: "13.1", + }, + { + emoji: "👨‍🦰", + aliases: ["red_haired_man"], + tags: [], + category: "People & Body", + description: "man: red hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦱", + aliases: ["curly_haired_man"], + tags: [], + category: "People & Body", + description: "man: curly hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦳", + aliases: ["white_haired_man"], + tags: [], + category: "People & Body", + description: "man: white hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦲", + aliases: ["bald_man"], + tags: [], + category: "People & Body", + description: "man: bald", + unicode_version: "11.0", + }, + { + emoji: "👩", + aliases: ["woman"], + tags: ["girls"], + category: "People & Body", + description: "woman", + unicode_version: "6.0", + }, + { + emoji: "👩‍🦰", + aliases: ["red_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: red hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦰", + aliases: ["person_red_hair"], + tags: [], + category: "People & Body", + description: "person: red hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦱", + aliases: ["curly_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: curly hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦱", + aliases: ["person_curly_hair"], + tags: [], + category: "People & Body", + description: "person: curly hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦳", + aliases: ["white_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: white hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦳", + aliases: ["person_white_hair"], + tags: [], + category: "People & Body", + description: "person: white hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦲", + aliases: ["bald_woman"], + tags: [], + category: "People & Body", + description: "woman: bald", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦲", + aliases: ["person_bald"], + tags: [], + category: "People & Body", + description: "person: bald", + unicode_version: "12.1", + }, + { + emoji: "👱‍♀️", + aliases: ["blond_haired_woman", "blonde_woman"], + tags: [], + category: "People & Body", + description: "woman: blond hair", + unicode_version: "6.0", + }, + { + emoji: "👱‍♂️", + aliases: ["blond_haired_man"], + tags: [], + category: "People & Body", + description: "man: blond hair", + unicode_version: "11.0", + }, + { + emoji: "🧓", + aliases: ["older_adult"], + tags: [], + category: "People & Body", + description: "older person", + unicode_version: "11.0", + }, + { + emoji: "👴", + aliases: ["older_man"], + tags: [], + category: "People & Body", + description: "old man", + unicode_version: "6.0", + }, + { + emoji: "👵", + aliases: ["older_woman"], + tags: [], + category: "People & Body", + description: "old woman", + unicode_version: "6.0", + }, + { + emoji: "🙍", + aliases: ["frowning_person"], + tags: [], + category: "People & Body", + description: "person frowning", + unicode_version: "6.0", + }, + { + emoji: "🙍‍♂️", + aliases: ["frowning_man"], + tags: [], + category: "People & Body", + description: "man frowning", + unicode_version: "6.0", + }, + { + emoji: "🙍‍♀️", + aliases: ["frowning_woman"], + tags: [], + category: "People & Body", + description: "woman frowning", + unicode_version: "11.0", + }, + { + emoji: "🙎", + aliases: ["pouting_face"], + tags: [], + category: "People & Body", + description: "person pouting", + unicode_version: "6.0", + }, + { + emoji: "🙎‍♂️", + aliases: ["pouting_man"], + tags: [], + category: "People & Body", + description: "man pouting", + unicode_version: "6.0", + }, + { + emoji: "🙎‍♀️", + aliases: ["pouting_woman"], + tags: [], + category: "People & Body", + description: "woman pouting", + unicode_version: "11.0", + }, + { + emoji: "🙅", + aliases: ["no_good"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "person gesturing NO", + unicode_version: "6.0", + }, + { + emoji: "🙅‍♂️", + aliases: ["no_good_man", "ng_man"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "man gesturing NO", + unicode_version: "6.0", + }, + { + emoji: "🙅‍♀️", + aliases: ["no_good_woman", "ng_woman"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "woman gesturing NO", + unicode_version: "11.0", + }, + { + emoji: "🙆", + aliases: ["ok_person"], + tags: [], + category: "People & Body", + description: "person gesturing OK", + unicode_version: "6.0", + }, + { + emoji: "🙆‍♂️", + aliases: ["ok_man"], + tags: [], + category: "People & Body", + description: "man gesturing OK", + unicode_version: "6.0", + }, + { + emoji: "🙆‍♀️", + aliases: ["ok_woman"], + tags: [], + category: "People & Body", + description: "woman gesturing OK", + unicode_version: "11.0", + }, + { + emoji: "💁", + aliases: ["tipping_hand_person", "information_desk_person"], + tags: [], + category: "People & Body", + description: "person tipping hand", + unicode_version: "6.0", + }, + { + emoji: "💁‍♂️", + aliases: ["tipping_hand_man", "sassy_man"], + tags: ["information"], + category: "People & Body", + description: "man tipping hand", + unicode_version: "6.0", + }, + { + emoji: "💁‍♀️", + aliases: ["tipping_hand_woman", "sassy_woman"], + tags: ["information"], + category: "People & Body", + description: "woman tipping hand", + unicode_version: "11.0", + }, + { + emoji: "🙋", + aliases: ["raising_hand"], + tags: [], + category: "People & Body", + description: "person raising hand", + unicode_version: "6.0", + }, + { + emoji: "🙋‍♂️", + aliases: ["raising_hand_man"], + tags: [], + category: "People & Body", + description: "man raising hand", + unicode_version: "6.0", + }, + { + emoji: "🙋‍♀️", + aliases: ["raising_hand_woman"], + tags: [], + category: "People & Body", + description: "woman raising hand", + unicode_version: "11.0", + }, + { + emoji: "🧏", + aliases: ["deaf_person"], + tags: [], + category: "People & Body", + description: "deaf person", + unicode_version: "12.0", + }, + { + emoji: "🧏‍♂️", + aliases: ["deaf_man"], + tags: [], + category: "People & Body", + description: "deaf man", + unicode_version: "12.0", + }, + { + emoji: "🧏‍♀️", + aliases: ["deaf_woman"], + tags: [], + category: "People & Body", + description: "deaf woman", + unicode_version: "12.0", + }, + { + emoji: "🙇", + aliases: ["bow"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "person bowing", + unicode_version: "6.0", + }, + { + emoji: "🙇‍♂️", + aliases: ["bowing_man"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "man bowing", + unicode_version: "11.0", + }, + { + emoji: "🙇‍♀️", + aliases: ["bowing_woman"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "woman bowing", + unicode_version: "6.0", + }, + { + emoji: "🤦", + aliases: ["facepalm"], + tags: [], + category: "People & Body", + description: "person facepalming", + unicode_version: "11.0", + }, + { + emoji: "🤦‍♂️", + aliases: ["man_facepalming"], + tags: [], + category: "People & Body", + description: "man facepalming", + unicode_version: "9.0", + }, + { + emoji: "🤦‍♀️", + aliases: ["woman_facepalming"], + tags: [], + category: "People & Body", + description: "woman facepalming", + unicode_version: "9.0", + }, + { + emoji: "🤷", + aliases: ["shrug"], + tags: [], + category: "People & Body", + description: "person shrugging", + unicode_version: "11.0", + }, + { + emoji: "🤷‍♂️", + aliases: ["man_shrugging"], + tags: [], + category: "People & Body", + description: "man shrugging", + unicode_version: "9.0", + }, + { + emoji: "🤷‍♀️", + aliases: ["woman_shrugging"], + tags: [], + category: "People & Body", + description: "woman shrugging", + unicode_version: "9.0", + }, + { + emoji: "🧑‍⚕️", + aliases: ["health_worker"], + tags: [], + category: "People & Body", + description: "health worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍⚕️", + aliases: ["man_health_worker"], + tags: ["doctor", "nurse"], + category: "People & Body", + description: "man health worker", + unicode_version: "", + }, + { + emoji: "👩‍⚕️", + aliases: ["woman_health_worker"], + tags: ["doctor", "nurse"], + category: "People & Body", + description: "woman health worker", + unicode_version: "", + }, + { + emoji: "🧑‍🎓", + aliases: ["student"], + tags: [], + category: "People & Body", + description: "student", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎓", + aliases: ["man_student"], + tags: ["graduation"], + category: "People & Body", + description: "man student", + unicode_version: "", + }, + { + emoji: "👩‍🎓", + aliases: ["woman_student"], + tags: ["graduation"], + category: "People & Body", + description: "woman student", + unicode_version: "", + }, + { + emoji: "🧑‍🏫", + aliases: ["teacher"], + tags: [], + category: "People & Body", + description: "teacher", + unicode_version: "12.1", + }, + { + emoji: "👨‍🏫", + aliases: ["man_teacher"], + tags: ["school", "professor"], + category: "People & Body", + description: "man teacher", + unicode_version: "", + }, + { + emoji: "👩‍🏫", + aliases: ["woman_teacher"], + tags: ["school", "professor"], + category: "People & Body", + description: "woman teacher", + unicode_version: "", + }, + { + emoji: "🧑‍⚖️", + aliases: ["judge"], + tags: [], + category: "People & Body", + description: "judge", + unicode_version: "12.1", + }, + { + emoji: "👨‍⚖️", + aliases: ["man_judge"], + tags: ["justice"], + category: "People & Body", + description: "man judge", + unicode_version: "", + }, + { + emoji: "👩‍⚖️", + aliases: ["woman_judge"], + tags: ["justice"], + category: "People & Body", + description: "woman judge", + unicode_version: "", + }, + { + emoji: "🧑‍🌾", + aliases: ["farmer"], + tags: [], + category: "People & Body", + description: "farmer", + unicode_version: "12.1", + }, + { + emoji: "👨‍🌾", + aliases: ["man_farmer"], + tags: [], + category: "People & Body", + description: "man farmer", + unicode_version: "", + }, + { + emoji: "👩‍🌾", + aliases: ["woman_farmer"], + tags: [], + category: "People & Body", + description: "woman farmer", + unicode_version: "", + }, + { + emoji: "🧑‍🍳", + aliases: ["cook"], + tags: [], + category: "People & Body", + description: "cook", + unicode_version: "12.1", + }, + { + emoji: "👨‍🍳", + aliases: ["man_cook"], + tags: ["chef"], + category: "People & Body", + description: "man cook", + unicode_version: "", + }, + { + emoji: "👩‍🍳", + aliases: ["woman_cook"], + tags: ["chef"], + category: "People & Body", + description: "woman cook", + unicode_version: "", + }, + { + emoji: "🧑‍🔧", + aliases: ["mechanic"], + tags: [], + category: "People & Body", + description: "mechanic", + unicode_version: "12.1", + }, + { + emoji: "👨‍🔧", + aliases: ["man_mechanic"], + tags: [], + category: "People & Body", + description: "man mechanic", + unicode_version: "", + }, + { + emoji: "👩‍🔧", + aliases: ["woman_mechanic"], + tags: [], + category: "People & Body", + description: "woman mechanic", + unicode_version: "", + }, + { + emoji: "🧑‍🏭", + aliases: ["factory_worker"], + tags: [], + category: "People & Body", + description: "factory worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍🏭", + aliases: ["man_factory_worker"], + tags: [], + category: "People & Body", + description: "man factory worker", + unicode_version: "", + }, + { + emoji: "👩‍🏭", + aliases: ["woman_factory_worker"], + tags: [], + category: "People & Body", + description: "woman factory worker", + unicode_version: "", + }, + { + emoji: "🧑‍💼", + aliases: ["office_worker"], + tags: [], + category: "People & Body", + description: "office worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍💼", + aliases: ["man_office_worker"], + tags: ["business"], + category: "People & Body", + description: "man office worker", + unicode_version: "", + }, + { + emoji: "👩‍💼", + aliases: ["woman_office_worker"], + tags: ["business"], + category: "People & Body", + description: "woman office worker", + unicode_version: "", + }, + { + emoji: "🧑‍🔬", + aliases: ["scientist"], + tags: [], + category: "People & Body", + description: "scientist", + unicode_version: "12.1", + }, + { + emoji: "👨‍🔬", + aliases: ["man_scientist"], + tags: ["research"], + category: "People & Body", + description: "man scientist", + unicode_version: "", + }, + { + emoji: "👩‍🔬", + aliases: ["woman_scientist"], + tags: ["research"], + category: "People & Body", + description: "woman scientist", + unicode_version: "", + }, + { + emoji: "🧑‍💻", + aliases: ["technologist"], + tags: [], + category: "People & Body", + description: "technologist", + unicode_version: "12.1", + }, + { + emoji: "👨‍💻", + aliases: ["man_technologist"], + tags: ["coder"], + category: "People & Body", + description: "man technologist", + unicode_version: "", + }, + { + emoji: "👩‍💻", + aliases: ["woman_technologist"], + tags: ["coder"], + category: "People & Body", + description: "woman technologist", + unicode_version: "", + }, + { + emoji: "🧑‍🎤", + aliases: ["singer"], + tags: [], + category: "People & Body", + description: "singer", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎤", + aliases: ["man_singer"], + tags: ["rockstar"], + category: "People & Body", + description: "man singer", + unicode_version: "", + }, + { + emoji: "👩‍🎤", + aliases: ["woman_singer"], + tags: ["rockstar"], + category: "People & Body", + description: "woman singer", + unicode_version: "", + }, + { + emoji: "🧑‍🎨", + aliases: ["artist"], + tags: [], + category: "People & Body", + description: "artist", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎨", + aliases: ["man_artist"], + tags: ["painter"], + category: "People & Body", + description: "man artist", + unicode_version: "", + }, + { + emoji: "👩‍🎨", + aliases: ["woman_artist"], + tags: ["painter"], + category: "People & Body", + description: "woman artist", + unicode_version: "", + }, + { + emoji: "🧑‍✈️", + aliases: ["pilot"], + tags: [], + category: "People & Body", + description: "pilot", + unicode_version: "12.1", + }, + { + emoji: "👨‍✈️", + aliases: ["man_pilot"], + tags: [], + category: "People & Body", + description: "man pilot", + unicode_version: "", + }, + { + emoji: "👩‍✈️", + aliases: ["woman_pilot"], + tags: [], + category: "People & Body", + description: "woman pilot", + unicode_version: "", + }, + { + emoji: "🧑‍🚀", + aliases: ["astronaut"], + tags: [], + category: "People & Body", + description: "astronaut", + unicode_version: "12.1", + }, + { + emoji: "👨‍🚀", + aliases: ["man_astronaut"], + tags: ["space"], + category: "People & Body", + description: "man astronaut", + unicode_version: "", + }, + { + emoji: "👩‍🚀", + aliases: ["woman_astronaut"], + tags: ["space"], + category: "People & Body", + description: "woman astronaut", + unicode_version: "", + }, + { + emoji: "🧑‍🚒", + aliases: ["firefighter"], + tags: [], + category: "People & Body", + description: "firefighter", + unicode_version: "12.1", + }, + { + emoji: "👨‍🚒", + aliases: ["man_firefighter"], + tags: [], + category: "People & Body", + description: "man firefighter", + unicode_version: "", + }, + { + emoji: "👩‍🚒", + aliases: ["woman_firefighter"], + tags: [], + category: "People & Body", + description: "woman firefighter", + unicode_version: "", + }, + { + emoji: "👮", + aliases: ["police_officer", "cop"], + tags: ["law"], + category: "People & Body", + description: "police officer", + unicode_version: "6.0", + }, + { + emoji: "👮‍♂️", + aliases: ["policeman"], + tags: ["law", "cop"], + category: "People & Body", + description: "man police officer", + unicode_version: "11.0", + }, + { + emoji: "👮‍♀️", + aliases: ["policewoman"], + tags: ["law", "cop"], + category: "People & Body", + description: "woman police officer", + unicode_version: "6.0", + }, + { + emoji: "🕵️", + aliases: ["detective"], + tags: ["sleuth"], + category: "People & Body", + description: "detective", + unicode_version: "7.0", + }, + { + emoji: "🕵️‍♂️", + aliases: ["male_detective"], + tags: ["sleuth"], + category: "People & Body", + description: "man detective", + unicode_version: "11.0", + }, + { + emoji: "🕵️‍♀️", + aliases: ["female_detective"], + tags: ["sleuth"], + category: "People & Body", + description: "woman detective", + unicode_version: "6.0", + }, + { + emoji: "💂", + aliases: ["guard"], + tags: [], + category: "People & Body", + description: "guard", + unicode_version: "6.0", + }, + { + emoji: "💂‍♂️", + aliases: ["guardsman"], + tags: [], + category: "People & Body", + description: "man guard", + unicode_version: "11.0", + }, + { + emoji: "💂‍♀️", + aliases: ["guardswoman"], + tags: [], + category: "People & Body", + description: "woman guard", + unicode_version: "6.0", + }, + { + emoji: "🥷", + aliases: ["ninja"], + tags: [], + category: "People & Body", + description: "ninja", + unicode_version: "13.0", + }, + { + emoji: "👷", + aliases: ["construction_worker"], + tags: ["helmet"], + category: "People & Body", + description: "construction worker", + unicode_version: "6.0", + }, + { + emoji: "👷‍♂️", + aliases: ["construction_worker_man"], + tags: ["helmet"], + category: "People & Body", + description: "man construction worker", + unicode_version: "11.0", + }, + { + emoji: "👷‍♀️", + aliases: ["construction_worker_woman"], + tags: ["helmet"], + category: "People & Body", + description: "woman construction worker", + unicode_version: "6.0", + }, + { + emoji: "🤴", + aliases: ["prince"], + tags: ["crown", "royal"], + category: "People & Body", + description: "prince", + unicode_version: "9.0", + }, + { + emoji: "👸", + aliases: ["princess"], + tags: ["crown", "royal"], + category: "People & Body", + description: "princess", + unicode_version: "6.0", + }, + { + emoji: "👳", + aliases: ["person_with_turban"], + tags: [], + category: "People & Body", + description: "person wearing turban", + unicode_version: "6.0", + }, + { + emoji: "👳‍♂️", + aliases: ["man_with_turban"], + tags: [], + category: "People & Body", + description: "man wearing turban", + unicode_version: "11.0", + }, + { + emoji: "👳‍♀️", + aliases: ["woman_with_turban"], + tags: [], + category: "People & Body", + description: "woman wearing turban", + unicode_version: "6.0", + }, + { + emoji: "👲", + aliases: ["man_with_gua_pi_mao"], + tags: [], + category: "People & Body", + description: "person with skullcap", + unicode_version: "6.0", + }, + { + emoji: "🧕", + aliases: ["woman_with_headscarf"], + tags: ["hijab"], + category: "People & Body", + description: "woman with headscarf", + unicode_version: "11.0", + }, + { + emoji: "🤵", + aliases: ["person_in_tuxedo"], + tags: ["groom", "marriage", "wedding"], + category: "People & Body", + description: "person in tuxedo", + unicode_version: "9.0", + }, + { + emoji: "🤵‍♂️", + aliases: ["man_in_tuxedo"], + tags: [], + category: "People & Body", + description: "man in tuxedo", + unicode_version: "13.0", + }, + { + emoji: "🤵‍♀️", + aliases: ["woman_in_tuxedo"], + tags: [], + category: "People & Body", + description: "woman in tuxedo", + unicode_version: "13.0", + }, + { + emoji: "👰", + aliases: ["person_with_veil"], + tags: ["marriage", "wedding"], + category: "People & Body", + description: "person with veil", + unicode_version: "6.0", + }, + { + emoji: "👰‍♂️", + aliases: ["man_with_veil"], + tags: [], + category: "People & Body", + description: "man with veil", + unicode_version: "13.0", + }, + { + emoji: "👰‍♀️", + aliases: ["woman_with_veil", "bride_with_veil"], + tags: [], + category: "People & Body", + description: "woman with veil", + unicode_version: "13.0", + }, + { + emoji: "🤰", + aliases: ["pregnant_woman"], + tags: [], + category: "People & Body", + description: "pregnant woman", + unicode_version: "9.0", + }, + { + emoji: "🤱", + aliases: ["breast_feeding"], + tags: ["nursing"], + category: "People & Body", + description: "breast-feeding", + unicode_version: "11.0", + }, + { + emoji: "👩‍🍼", + aliases: ["woman_feeding_baby"], + tags: [], + category: "People & Body", + description: "woman feeding baby", + unicode_version: "13.0", + }, + { + emoji: "👨‍🍼", + aliases: ["man_feeding_baby"], + tags: [], + category: "People & Body", + description: "man feeding baby", + unicode_version: "13.0", + }, + { + emoji: "🧑‍🍼", + aliases: ["person_feeding_baby"], + tags: [], + category: "People & Body", + description: "person feeding baby", + unicode_version: "13.0", + }, + { + emoji: "👼", + aliases: ["angel"], + tags: [], + category: "People & Body", + description: "baby angel", + unicode_version: "6.0", + }, + { + emoji: "🎅", + aliases: ["santa"], + tags: ["christmas"], + category: "People & Body", + description: "Santa Claus", + unicode_version: "6.0", + }, + { + emoji: "🤶", + aliases: ["mrs_claus"], + tags: ["santa"], + category: "People & Body", + description: "Mrs. Claus", + unicode_version: "9.0", + }, + { + emoji: "🧑‍🎄", + aliases: ["mx_claus"], + tags: [], + category: "People & Body", + description: "mx claus", + unicode_version: "13.0", + }, + { + emoji: "🦸", + aliases: ["superhero"], + tags: [], + category: "People & Body", + description: "superhero", + unicode_version: "11.0", + }, + { + emoji: "🦸‍♂️", + aliases: ["superhero_man"], + tags: [], + category: "People & Body", + description: "man superhero", + unicode_version: "11.0", + }, + { + emoji: "🦸‍♀️", + aliases: ["superhero_woman"], + tags: [], + category: "People & Body", + description: "woman superhero", + unicode_version: "11.0", + }, + { + emoji: "🦹", + aliases: ["supervillain"], + tags: [], + category: "People & Body", + description: "supervillain", + unicode_version: "11.0", + }, + { + emoji: "🦹‍♂️", + aliases: ["supervillain_man"], + tags: [], + category: "People & Body", + description: "man supervillain", + unicode_version: "11.0", + }, + { + emoji: "🦹‍♀️", + aliases: ["supervillain_woman"], + tags: [], + category: "People & Body", + description: "woman supervillain", + unicode_version: "11.0", + }, + { + emoji: "🧙", + aliases: ["mage"], + tags: ["wizard"], + category: "People & Body", + description: "mage", + unicode_version: "11.0", + }, + { + emoji: "🧙‍♂️", + aliases: ["mage_man"], + tags: ["wizard"], + category: "People & Body", + description: "man mage", + unicode_version: "11.0", + }, + { + emoji: "🧙‍♀️", + aliases: ["mage_woman"], + tags: ["wizard"], + category: "People & Body", + description: "woman mage", + unicode_version: "11.0", + }, + { + emoji: "🧚", + aliases: ["fairy"], + tags: [], + category: "People & Body", + description: "fairy", + unicode_version: "11.0", + }, + { + emoji: "🧚‍♂️", + aliases: ["fairy_man"], + tags: [], + category: "People & Body", + description: "man fairy", + unicode_version: "11.0", + }, + { + emoji: "🧚‍♀️", + aliases: ["fairy_woman"], + tags: [], + category: "People & Body", + description: "woman fairy", + unicode_version: "11.0", + }, + { + emoji: "🧛", + aliases: ["vampire"], + tags: [], + category: "People & Body", + description: "vampire", + unicode_version: "11.0", + }, + { + emoji: "🧛‍♂️", + aliases: ["vampire_man"], + tags: [], + category: "People & Body", + description: "man vampire", + unicode_version: "11.0", + }, + { + emoji: "🧛‍♀️", + aliases: ["vampire_woman"], + tags: [], + category: "People & Body", + description: "woman vampire", + unicode_version: "11.0", + }, + { + emoji: "🧜", + aliases: ["merperson"], + tags: [], + category: "People & Body", + description: "merperson", + unicode_version: "11.0", + }, + { + emoji: "🧜‍♂️", + aliases: ["merman"], + tags: [], + category: "People & Body", + description: "merman", + unicode_version: "11.0", + }, + { + emoji: "🧜‍♀️", + aliases: ["mermaid"], + tags: [], + category: "People & Body", + description: "mermaid", + unicode_version: "11.0", + }, + { + emoji: "🧝", + aliases: ["elf"], + tags: [], + category: "People & Body", + description: "elf", + unicode_version: "11.0", + }, + { + emoji: "🧝‍♂️", + aliases: ["elf_man"], + tags: [], + category: "People & Body", + description: "man elf", + unicode_version: "11.0", + }, + { + emoji: "🧝‍♀️", + aliases: ["elf_woman"], + tags: [], + category: "People & Body", + description: "woman elf", + unicode_version: "11.0", + }, + { + emoji: "🧞", + aliases: ["genie"], + tags: [], + category: "People & Body", + description: "genie", + unicode_version: "11.0", + }, + { + emoji: "🧞‍♂️", + aliases: ["genie_man"], + tags: [], + category: "People & Body", + description: "man genie", + unicode_version: "11.0", + }, + { + emoji: "🧞‍♀️", + aliases: ["genie_woman"], + tags: [], + category: "People & Body", + description: "woman genie", + unicode_version: "11.0", + }, + { + emoji: "🧟", + aliases: ["zombie"], + tags: [], + category: "People & Body", + description: "zombie", + unicode_version: "11.0", + }, + { + emoji: "🧟‍♂️", + aliases: ["zombie_man"], + tags: [], + category: "People & Body", + description: "man zombie", + unicode_version: "11.0", + }, + { + emoji: "🧟‍♀️", + aliases: ["zombie_woman"], + tags: [], + category: "People & Body", + description: "woman zombie", + unicode_version: "11.0", + }, + { + emoji: "💆", + aliases: ["massage"], + tags: ["spa"], + category: "People & Body", + description: "person getting massage", + unicode_version: "6.0", + }, + { + emoji: "💆‍♂️", + aliases: ["massage_man"], + tags: ["spa"], + category: "People & Body", + description: "man getting massage", + unicode_version: "6.0", + }, + { + emoji: "💆‍♀️", + aliases: ["massage_woman"], + tags: ["spa"], + category: "People & Body", + description: "woman getting massage", + unicode_version: "11.0", + }, + { + emoji: "💇", + aliases: ["haircut"], + tags: ["beauty"], + category: "People & Body", + description: "person getting haircut", + unicode_version: "6.0", + }, + { + emoji: "💇‍♂️", + aliases: ["haircut_man"], + tags: [], + category: "People & Body", + description: "man getting haircut", + unicode_version: "6.0", + }, + { + emoji: "💇‍♀️", + aliases: ["haircut_woman"], + tags: [], + category: "People & Body", + description: "woman getting haircut", + unicode_version: "11.0", + }, + { + emoji: "🚶", + aliases: ["walking"], + tags: [], + category: "People & Body", + description: "person walking", + unicode_version: "6.0", + }, + { + emoji: "🚶‍♂️", + aliases: ["walking_man"], + tags: [], + category: "People & Body", + description: "man walking", + unicode_version: "11.0", + }, + { + emoji: "🚶‍♀️", + aliases: ["walking_woman"], + tags: [], + category: "People & Body", + description: "woman walking", + unicode_version: "6.0", + }, + { + emoji: "🧍", + aliases: ["standing_person"], + tags: [], + category: "People & Body", + description: "person standing", + unicode_version: "12.0", + }, + { + emoji: "🧍‍♂️", + aliases: ["standing_man"], + tags: [], + category: "People & Body", + description: "man standing", + unicode_version: "12.0", + }, + { + emoji: "🧍‍♀️", + aliases: ["standing_woman"], + tags: [], + category: "People & Body", + description: "woman standing", + unicode_version: "12.0", + }, + { + emoji: "🧎", + aliases: ["kneeling_person"], + tags: [], + category: "People & Body", + description: "person kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧎‍♂️", + aliases: ["kneeling_man"], + tags: [], + category: "People & Body", + description: "man kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧎‍♀️", + aliases: ["kneeling_woman"], + tags: [], + category: "People & Body", + description: "woman kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦯", + aliases: ["person_with_probing_cane"], + tags: [], + category: "People & Body", + description: "person with white cane", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦯", + aliases: ["man_with_probing_cane"], + tags: [], + category: "People & Body", + description: "man with white cane", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦯", + aliases: ["woman_with_probing_cane"], + tags: [], + category: "People & Body", + description: "woman with white cane", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦼", + aliases: ["person_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "person in motorized wheelchair", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦼", + aliases: ["man_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "man in motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦼", + aliases: ["woman_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "woman in motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦽", + aliases: ["person_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "person in manual wheelchair", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦽", + aliases: ["man_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "man in manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦽", + aliases: ["woman_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "woman in manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🏃", + aliases: ["runner", "running"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "person running", + unicode_version: "6.0", + }, + { + emoji: "🏃‍♂️", + aliases: ["running_man"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "man running", + unicode_version: "11.0", + }, + { + emoji: "🏃‍♀️", + aliases: ["running_woman"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "woman running", + unicode_version: "6.0", + }, + { + emoji: "💃", + aliases: ["woman_dancing", "dancer"], + tags: ["dress"], + category: "People & Body", + description: "woman dancing", + unicode_version: "6.0", + }, + { + emoji: "🕺", + aliases: ["man_dancing"], + tags: ["dancer"], + category: "People & Body", + description: "man dancing", + unicode_version: "9.0", + }, + { + emoji: "🕴️", + aliases: ["business_suit_levitating"], + tags: [], + category: "People & Body", + description: "person in suit levitating", + unicode_version: "7.0", + }, + { + emoji: "👯", + aliases: ["dancers"], + tags: ["bunny"], + category: "People & Body", + description: "people with bunny ears", + unicode_version: "6.0", + }, + { + emoji: "👯‍♂️", + aliases: ["dancing_men"], + tags: ["bunny"], + category: "People & Body", + description: "men with bunny ears", + unicode_version: "6.0", + }, + { + emoji: "👯‍♀️", + aliases: ["dancing_women"], + tags: ["bunny"], + category: "People & Body", + description: "women with bunny ears", + unicode_version: "11.0", + }, + { + emoji: "🧖", + aliases: ["sauna_person"], + tags: ["steamy"], + category: "People & Body", + description: "person in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧖‍♂️", + aliases: ["sauna_man"], + tags: ["steamy"], + category: "People & Body", + description: "man in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧖‍♀️", + aliases: ["sauna_woman"], + tags: ["steamy"], + category: "People & Body", + description: "woman in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧗", + aliases: ["climbing"], + tags: ["bouldering"], + category: "People & Body", + description: "person climbing", + unicode_version: "11.0", + }, + { + emoji: "🧗‍♂️", + aliases: ["climbing_man"], + tags: ["bouldering"], + category: "People & Body", + description: "man climbing", + unicode_version: "11.0", + }, + { + emoji: "🧗‍♀️", + aliases: ["climbing_woman"], + tags: ["bouldering"], + category: "People & Body", + description: "woman climbing", + unicode_version: "11.0", + }, + { + emoji: "🤺", + aliases: ["person_fencing"], + tags: [], + category: "People & Body", + description: "person fencing", + unicode_version: "9.0", + }, + { + emoji: "🏇", + aliases: ["horse_racing"], + tags: [], + category: "People & Body", + description: "horse racing", + unicode_version: "6.0", + }, + { + emoji: "⛷️", + aliases: ["skier"], + tags: [], + category: "People & Body", + description: "skier", + unicode_version: "5.2", + }, + { + emoji: "🏂", + aliases: ["snowboarder"], + tags: [], + category: "People & Body", + description: "snowboarder", + unicode_version: "6.0", + }, + { + emoji: "🏌️", + aliases: ["golfing"], + tags: [], + category: "People & Body", + description: "person golfing", + unicode_version: "7.0", + }, + { + emoji: "🏌️‍♂️", + aliases: ["golfing_man"], + tags: [], + category: "People & Body", + description: "man golfing", + unicode_version: "11.0", + }, + { + emoji: "🏌️‍♀️", + aliases: ["golfing_woman"], + tags: [], + category: "People & Body", + description: "woman golfing", + unicode_version: "", + }, + { + emoji: "🏄", + aliases: ["surfer"], + tags: [], + category: "People & Body", + description: "person surfing", + unicode_version: "6.0", + }, + { + emoji: "🏄‍♂️", + aliases: ["surfing_man"], + tags: [], + category: "People & Body", + description: "man surfing", + unicode_version: "11.0", + }, + { + emoji: "🏄‍♀️", + aliases: ["surfing_woman"], + tags: [], + category: "People & Body", + description: "woman surfing", + unicode_version: "7.0", + }, + { + emoji: "🚣", + aliases: ["rowboat"], + tags: [], + category: "People & Body", + description: "person rowing boat", + unicode_version: "6.0", + }, + { + emoji: "🚣‍♂️", + aliases: ["rowing_man"], + tags: [], + category: "People & Body", + description: "man rowing boat", + unicode_version: "11.0", + }, + { + emoji: "🚣‍♀️", + aliases: ["rowing_woman"], + tags: [], + category: "People & Body", + description: "woman rowing boat", + unicode_version: "6.0", + }, + { + emoji: "🏊", + aliases: ["swimmer"], + tags: [], + category: "People & Body", + description: "person swimming", + unicode_version: "6.0", + }, + { + emoji: "🏊‍♂️", + aliases: ["swimming_man"], + tags: [], + category: "People & Body", + description: "man swimming", + unicode_version: "11.0", + }, + { + emoji: "🏊‍♀️", + aliases: ["swimming_woman"], + tags: [], + category: "People & Body", + description: "woman swimming", + unicode_version: "6.0", + }, + { + emoji: "⛹️", + aliases: ["bouncing_ball_person"], + tags: ["basketball"], + category: "People & Body", + description: "person bouncing ball", + unicode_version: "5.2", + }, + { + emoji: "⛹️‍♂️", + aliases: ["bouncing_ball_man", "basketball_man"], + tags: [], + category: "People & Body", + description: "man bouncing ball", + unicode_version: "11.0", + }, + { + emoji: "⛹️‍♀️", + aliases: ["bouncing_ball_woman", "basketball_woman"], + tags: [], + category: "People & Body", + description: "woman bouncing ball", + unicode_version: "7.0", + }, + { + emoji: "🏋️", + aliases: ["weight_lifting"], + tags: ["gym", "workout"], + category: "People & Body", + description: "person lifting weights", + unicode_version: "7.0", + }, + { + emoji: "🏋️‍♂️", + aliases: ["weight_lifting_man"], + tags: ["gym", "workout"], + category: "People & Body", + description: "man lifting weights", + unicode_version: "11.0", + }, + { + emoji: "🏋️‍♀️", + aliases: ["weight_lifting_woman"], + tags: ["gym", "workout"], + category: "People & Body", + description: "woman lifting weights", + unicode_version: "6.0", + }, + { + emoji: "🚴", + aliases: ["bicyclist"], + tags: [], + category: "People & Body", + description: "person biking", + unicode_version: "6.0", + }, + { + emoji: "🚴‍♂️", + aliases: ["biking_man"], + tags: [], + category: "People & Body", + description: "man biking", + unicode_version: "11.0", + }, + { + emoji: "🚴‍♀️", + aliases: ["biking_woman"], + tags: [], + category: "People & Body", + description: "woman biking", + unicode_version: "6.0", + }, + { + emoji: "🚵", + aliases: ["mountain_bicyclist"], + tags: [], + category: "People & Body", + description: "person mountain biking", + unicode_version: "6.0", + }, + { + emoji: "🚵‍♂️", + aliases: ["mountain_biking_man"], + tags: [], + category: "People & Body", + description: "man mountain biking", + unicode_version: "11.0", + }, + { + emoji: "🚵‍♀️", + aliases: ["mountain_biking_woman"], + tags: [], + category: "People & Body", + description: "woman mountain biking", + unicode_version: "6.0", + }, + { + emoji: "🤸", + aliases: ["cartwheeling"], + tags: [], + category: "People & Body", + description: "person cartwheeling", + unicode_version: "11.0", + }, + { + emoji: "🤸‍♂️", + aliases: ["man_cartwheeling"], + tags: [], + category: "People & Body", + description: "man cartwheeling", + unicode_version: "", + }, + { + emoji: "🤸‍♀️", + aliases: ["woman_cartwheeling"], + tags: [], + category: "People & Body", + description: "woman cartwheeling", + unicode_version: "", + }, + { + emoji: "🤼", + aliases: ["wrestling"], + tags: [], + category: "People & Body", + description: "people wrestling", + unicode_version: "11.0", + }, + { + emoji: "🤼‍♂️", + aliases: ["men_wrestling"], + tags: [], + category: "People & Body", + description: "men wrestling", + unicode_version: "9.0", + }, + { + emoji: "🤼‍♀️", + aliases: ["women_wrestling"], + tags: [], + category: "People & Body", + description: "women wrestling", + unicode_version: "9.0", + }, + { + emoji: "🤽", + aliases: ["water_polo"], + tags: [], + category: "People & Body", + description: "person playing water polo", + unicode_version: "11.0", + }, + { + emoji: "🤽‍♂️", + aliases: ["man_playing_water_polo"], + tags: [], + category: "People & Body", + description: "man playing water polo", + unicode_version: "9.0", + }, + { + emoji: "🤽‍♀️", + aliases: ["woman_playing_water_polo"], + tags: [], + category: "People & Body", + description: "woman playing water polo", + unicode_version: "9.0", + }, + { + emoji: "🤾", + aliases: ["handball_person"], + tags: [], + category: "People & Body", + description: "person playing handball", + unicode_version: "11.0", + }, + { + emoji: "🤾‍♂️", + aliases: ["man_playing_handball"], + tags: [], + category: "People & Body", + description: "man playing handball", + unicode_version: "9.0", + }, + { + emoji: "🤾‍♀️", + aliases: ["woman_playing_handball"], + tags: [], + category: "People & Body", + description: "woman playing handball", + unicode_version: "9.0", + }, + { + emoji: "🤹", + aliases: ["juggling_person"], + tags: [], + category: "People & Body", + description: "person juggling", + unicode_version: "11.0", + }, + { + emoji: "🤹‍♂️", + aliases: ["man_juggling"], + tags: [], + category: "People & Body", + description: "man juggling", + unicode_version: "9.0", + }, + { + emoji: "🤹‍♀️", + aliases: ["woman_juggling"], + tags: [], + category: "People & Body", + description: "woman juggling", + unicode_version: "9.0", + }, + { + emoji: "🧘", + aliases: ["lotus_position"], + tags: ["meditation"], + category: "People & Body", + description: "person in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🧘‍♂️", + aliases: ["lotus_position_man"], + tags: ["meditation"], + category: "People & Body", + description: "man in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🧘‍♀️", + aliases: ["lotus_position_woman"], + tags: ["meditation"], + category: "People & Body", + description: "woman in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🛀", + aliases: ["bath"], + tags: ["shower"], + category: "People & Body", + description: "person taking bath", + unicode_version: "6.0", + }, + { + emoji: "🛌", + aliases: ["sleeping_bed"], + tags: [], + category: "People & Body", + description: "person in bed", + unicode_version: "7.0", + }, + { + emoji: "🧑‍🤝‍🧑", + aliases: ["people_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "people holding hands", + unicode_version: "12.0", + }, + { + emoji: "👭", + aliases: ["two_women_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "women holding hands", + unicode_version: "6.0", + }, + { + emoji: "👫", + aliases: ["couple"], + tags: ["date"], + category: "People & Body", + description: "woman and man holding hands", + unicode_version: "6.0", + }, + { + emoji: "👬", + aliases: ["two_men_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "men holding hands", + unicode_version: "6.0", + }, + { + emoji: "💏", + aliases: ["couplekiss"], + tags: [], + category: "People & Body", + description: "kiss", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍💋‍👨", + aliases: ["couplekiss_man_woman"], + tags: [], + category: "People & Body", + description: "kiss: woman, man", + unicode_version: "11.0", + }, + { + emoji: "👨‍❤️‍💋‍👨", + aliases: ["couplekiss_man_man"], + tags: [], + category: "People & Body", + description: "kiss: man, man", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍💋‍👩", + aliases: ["couplekiss_woman_woman"], + tags: [], + category: "People & Body", + description: "kiss: woman, woman", + unicode_version: "6.0", + }, + { + emoji: "💑", + aliases: ["couple_with_heart"], + tags: [], + category: "People & Body", + description: "couple with heart", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍👨", + aliases: ["couple_with_heart_woman_man"], + tags: [], + category: "People & Body", + description: "couple with heart: woman, man", + unicode_version: "11.0", + }, + { + emoji: "👨‍❤️‍👨", + aliases: ["couple_with_heart_man_man"], + tags: [], + category: "People & Body", + description: "couple with heart: man, man", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍👩", + aliases: ["couple_with_heart_woman_woman"], + tags: [], + category: "People & Body", + description: "couple with heart: woman, woman", + unicode_version: "6.0", + }, + { + emoji: "👪", + aliases: ["family"], + tags: ["home", "parents", "child"], + category: "People & Body", + description: "family", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👦", + aliases: ["family_man_woman_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, boy", + unicode_version: "11.0", + }, + { + emoji: "👨‍👩‍👧", + aliases: ["family_man_woman_girl"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👧‍👦", + aliases: ["family_man_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👦‍👦", + aliases: ["family_man_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👧‍👧", + aliases: ["family_man_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👦", + aliases: ["family_man_man_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧", + aliases: ["family_man_man_girl"], + tags: [], + category: "People & Body", + description: "family: man, man, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧‍👦", + aliases: ["family_man_man_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👦‍👦", + aliases: ["family_man_man_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧‍👧", + aliases: ["family_man_man_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, man, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👦", + aliases: ["family_woman_woman_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧", + aliases: ["family_woman_woman_girl"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧‍👦", + aliases: ["family_woman_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👦‍👦", + aliases: ["family_woman_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧‍👧", + aliases: ["family_woman_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👦", + aliases: ["family_man_boy"], + tags: [], + category: "People & Body", + description: "family: man, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👦‍👦", + aliases: ["family_man_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧", + aliases: ["family_man_girl"], + tags: [], + category: "People & Body", + description: "family: man, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧‍👦", + aliases: ["family_man_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧‍👧", + aliases: ["family_man_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👦", + aliases: ["family_woman_boy"], + tags: [], + category: "People & Body", + description: "family: woman, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👦‍👦", + aliases: ["family_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧", + aliases: ["family_woman_girl"], + tags: [], + category: "People & Body", + description: "family: woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧‍👦", + aliases: ["family_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧‍👧", + aliases: ["family_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "🗣️", + aliases: ["speaking_head"], + tags: [], + category: "People & Body", + description: "speaking head", + unicode_version: "7.0", + }, + { + emoji: "👤", + aliases: ["bust_in_silhouette"], + tags: ["user"], + category: "People & Body", + description: "bust in silhouette", + unicode_version: "6.0", + }, + { + emoji: "👥", + aliases: ["busts_in_silhouette"], + tags: ["users", "group", "team"], + category: "People & Body", + description: "busts in silhouette", + unicode_version: "6.0", + }, + { + emoji: "🫂", + aliases: ["people_hugging"], + tags: [], + category: "People & Body", + description: "people hugging", + unicode_version: "13.0", + }, + { + emoji: "👣", + aliases: ["footprints"], + tags: ["feet", "tracks"], + category: "People & Body", + description: "footprints", + unicode_version: "6.0", + }, + { + emoji: "🐵", + aliases: ["monkey_face"], + tags: [], + category: "Animals & Nature", + description: "monkey face", + unicode_version: "6.0", + }, + { + emoji: "🐒", + aliases: ["monkey"], + tags: [], + category: "Animals & Nature", + description: "monkey", + unicode_version: "6.0", + }, + { + emoji: "🦍", + aliases: ["gorilla"], + tags: [], + category: "Animals & Nature", + description: "gorilla", + unicode_version: "9.0", + }, + { + emoji: "🦧", + aliases: ["orangutan"], + tags: [], + category: "Animals & Nature", + description: "orangutan", + unicode_version: "12.0", + }, + { + emoji: "🐶", + aliases: ["dog"], + tags: ["pet"], + category: "Animals & Nature", + description: "dog face", + unicode_version: "6.0", + }, + { + emoji: "🐕", + aliases: ["dog2"], + tags: [], + category: "Animals & Nature", + description: "dog", + unicode_version: "6.0", + }, + { + emoji: "🦮", + aliases: ["guide_dog"], + tags: [], + category: "Animals & Nature", + description: "guide dog", + unicode_version: "12.0", + }, + { + emoji: "🐕‍🦺", + aliases: ["service_dog"], + tags: [], + category: "Animals & Nature", + description: "service dog", + unicode_version: "12.0", + }, + { + emoji: "🐩", + aliases: ["poodle"], + tags: ["dog"], + category: "Animals & Nature", + description: "poodle", + unicode_version: "6.0", + }, + { + emoji: "🐺", + aliases: ["wolf"], + tags: [], + category: "Animals & Nature", + description: "wolf", + unicode_version: "6.0", + }, + { + emoji: "🦊", + aliases: ["fox_face"], + tags: [], + category: "Animals & Nature", + description: "fox", + unicode_version: "9.0", + }, + { + emoji: "🦝", + aliases: ["raccoon"], + tags: [], + category: "Animals & Nature", + description: "raccoon", + unicode_version: "11.0", + }, + { + emoji: "🐱", + aliases: ["cat"], + tags: ["pet"], + category: "Animals & Nature", + description: "cat face", + unicode_version: "6.0", + }, + { + emoji: "🐈", + aliases: ["cat2"], + tags: [], + category: "Animals & Nature", + description: "cat", + unicode_version: "6.0", + }, + { + emoji: "🐈‍⬛", + aliases: ["black_cat"], + tags: [], + category: "Animals & Nature", + description: "black cat", + unicode_version: "13.0", + }, + { + emoji: "🦁", + aliases: ["lion"], + tags: [], + category: "Animals & Nature", + description: "lion", + unicode_version: "8.0", + }, + { + emoji: "🐯", + aliases: ["tiger"], + tags: [], + category: "Animals & Nature", + description: "tiger face", + unicode_version: "6.0", + }, + { + emoji: "🐅", + aliases: ["tiger2"], + tags: [], + category: "Animals & Nature", + description: "tiger", + unicode_version: "6.0", + }, + { + emoji: "🐆", + aliases: ["leopard"], + tags: [], + category: "Animals & Nature", + description: "leopard", + unicode_version: "6.0", + }, + { + emoji: "🐴", + aliases: ["horse"], + tags: [], + category: "Animals & Nature", + description: "horse face", + unicode_version: "6.0", + }, + { + emoji: "🐎", + aliases: ["racehorse"], + tags: ["speed"], + category: "Animals & Nature", + description: "horse", + unicode_version: "6.0", + }, + { + emoji: "🦄", + aliases: ["unicorn"], + tags: [], + category: "Animals & Nature", + description: "unicorn", + unicode_version: "8.0", + }, + { + emoji: "🦓", + aliases: ["zebra"], + tags: [], + category: "Animals & Nature", + description: "zebra", + unicode_version: "11.0", + }, + { + emoji: "🦌", + aliases: ["deer"], + tags: [], + category: "Animals & Nature", + description: "deer", + unicode_version: "9.0", + }, + { + emoji: "🦬", + aliases: ["bison"], + tags: [], + category: "Animals & Nature", + description: "bison", + unicode_version: "13.0", + }, + { + emoji: "🐮", + aliases: ["cow"], + tags: [], + category: "Animals & Nature", + description: "cow face", + unicode_version: "6.0", + }, + { + emoji: "🐂", + aliases: ["ox"], + tags: [], + category: "Animals & Nature", + description: "ox", + unicode_version: "6.0", + }, + { + emoji: "🐃", + aliases: ["water_buffalo"], + tags: [], + category: "Animals & Nature", + description: "water buffalo", + unicode_version: "6.0", + }, + { + emoji: "🐄", + aliases: ["cow2"], + tags: [], + category: "Animals & Nature", + description: "cow", + unicode_version: "6.0", + }, + { + emoji: "🐷", + aliases: ["pig"], + tags: [], + category: "Animals & Nature", + description: "pig face", + unicode_version: "6.0", + }, + { + emoji: "🐖", + aliases: ["pig2"], + tags: [], + category: "Animals & Nature", + description: "pig", + unicode_version: "6.0", + }, + { + emoji: "🐗", + aliases: ["boar"], + tags: [], + category: "Animals & Nature", + description: "boar", + unicode_version: "6.0", + }, + { + emoji: "🐽", + aliases: ["pig_nose"], + tags: [], + category: "Animals & Nature", + description: "pig nose", + unicode_version: "6.0", + }, + { + emoji: "🐏", + aliases: ["ram"], + tags: [], + category: "Animals & Nature", + description: "ram", + unicode_version: "6.0", + }, + { + emoji: "🐑", + aliases: ["sheep"], + tags: [], + category: "Animals & Nature", + description: "ewe", + unicode_version: "6.0", + }, + { + emoji: "🐐", + aliases: ["goat"], + tags: [], + category: "Animals & Nature", + description: "goat", + unicode_version: "6.0", + }, + { + emoji: "🐪", + aliases: ["dromedary_camel"], + tags: ["desert"], + category: "Animals & Nature", + description: "camel", + unicode_version: "6.0", + }, + { + emoji: "🐫", + aliases: ["camel"], + tags: [], + category: "Animals & Nature", + description: "two-hump camel", + unicode_version: "6.0", + }, + { + emoji: "🦙", + aliases: ["llama"], + tags: [], + category: "Animals & Nature", + description: "llama", + unicode_version: "11.0", + }, + { + emoji: "🦒", + aliases: ["giraffe"], + tags: [], + category: "Animals & Nature", + description: "giraffe", + unicode_version: "11.0", + }, + { + emoji: "🐘", + aliases: ["elephant"], + tags: [], + category: "Animals & Nature", + description: "elephant", + unicode_version: "6.0", + }, + { + emoji: "🦣", + aliases: ["mammoth"], + tags: [], + category: "Animals & Nature", + description: "mammoth", + unicode_version: "13.0", + }, + { + emoji: "🦏", + aliases: ["rhinoceros"], + tags: [], + category: "Animals & Nature", + description: "rhinoceros", + unicode_version: "9.0", + }, + { + emoji: "🦛", + aliases: ["hippopotamus"], + tags: [], + category: "Animals & Nature", + description: "hippopotamus", + unicode_version: "11.0", + }, + { + emoji: "🐭", + aliases: ["mouse"], + tags: [], + category: "Animals & Nature", + description: "mouse face", + unicode_version: "6.0", + }, + { + emoji: "🐁", + aliases: ["mouse2"], + tags: [], + category: "Animals & Nature", + description: "mouse", + unicode_version: "6.0", + }, + { + emoji: "🐀", + aliases: ["rat"], + tags: [], + category: "Animals & Nature", + description: "rat", + unicode_version: "6.0", + }, + { + emoji: "🐹", + aliases: ["hamster"], + tags: ["pet"], + category: "Animals & Nature", + description: "hamster", + unicode_version: "6.0", + }, + { + emoji: "🐰", + aliases: ["rabbit"], + tags: ["bunny"], + category: "Animals & Nature", + description: "rabbit face", + unicode_version: "6.0", + }, + { + emoji: "🐇", + aliases: ["rabbit2"], + tags: [], + category: "Animals & Nature", + description: "rabbit", + unicode_version: "6.0", + }, + { + emoji: "🐿️", + aliases: ["chipmunk"], + tags: [], + category: "Animals & Nature", + description: "chipmunk", + unicode_version: "7.0", + }, + { + emoji: "🦫", + aliases: ["beaver"], + tags: [], + category: "Animals & Nature", + description: "beaver", + unicode_version: "13.0", + }, + { + emoji: "🦔", + aliases: ["hedgehog"], + tags: [], + category: "Animals & Nature", + description: "hedgehog", + unicode_version: "11.0", + }, + { + emoji: "🦇", + aliases: ["bat"], + tags: [], + category: "Animals & Nature", + description: "bat", + unicode_version: "9.0", + }, + { + emoji: "🐻", + aliases: ["bear"], + tags: [], + category: "Animals & Nature", + description: "bear", + unicode_version: "6.0", + }, + { + emoji: "🐻‍❄️", + aliases: ["polar_bear"], + tags: [], + category: "Animals & Nature", + description: "polar bear", + unicode_version: "13.0", + }, + { + emoji: "🐨", + aliases: ["koala"], + tags: [], + category: "Animals & Nature", + description: "koala", + unicode_version: "6.0", + }, + { + emoji: "🐼", + aliases: ["panda_face"], + tags: [], + category: "Animals & Nature", + description: "panda", + unicode_version: "6.0", + }, + { + emoji: "🦥", + aliases: ["sloth"], + tags: [], + category: "Animals & Nature", + description: "sloth", + unicode_version: "12.0", + }, + { + emoji: "🦦", + aliases: ["otter"], + tags: [], + category: "Animals & Nature", + description: "otter", + unicode_version: "12.0", + }, + { + emoji: "🦨", + aliases: ["skunk"], + tags: [], + category: "Animals & Nature", + description: "skunk", + unicode_version: "12.0", + }, + { + emoji: "🦘", + aliases: ["kangaroo"], + tags: [], + category: "Animals & Nature", + description: "kangaroo", + unicode_version: "11.0", + }, + { + emoji: "🦡", + aliases: ["badger"], + tags: [], + category: "Animals & Nature", + description: "badger", + unicode_version: "11.0", + }, + { + emoji: "🐾", + aliases: ["feet", "paw_prints"], + tags: [], + category: "Animals & Nature", + description: "paw prints", + unicode_version: "6.0", + }, + { + emoji: "🦃", + aliases: ["turkey"], + tags: ["thanksgiving"], + category: "Animals & Nature", + description: "turkey", + unicode_version: "8.0", + }, + { + emoji: "🐔", + aliases: ["chicken"], + tags: [], + category: "Animals & Nature", + description: "chicken", + unicode_version: "6.0", + }, + { + emoji: "🐓", + aliases: ["rooster"], + tags: [], + category: "Animals & Nature", + description: "rooster", + unicode_version: "6.0", + }, + { + emoji: "🐣", + aliases: ["hatching_chick"], + tags: [], + category: "Animals & Nature", + description: "hatching chick", + unicode_version: "6.0", + }, + { + emoji: "🐤", + aliases: ["baby_chick"], + tags: [], + category: "Animals & Nature", + description: "baby chick", + unicode_version: "6.0", + }, + { + emoji: "🐥", + aliases: ["hatched_chick"], + tags: [], + category: "Animals & Nature", + description: "front-facing baby chick", + unicode_version: "6.0", + }, + { + emoji: "🐦", + aliases: ["bird"], + tags: [], + category: "Animals & Nature", + description: "bird", + unicode_version: "6.0", + }, + { + emoji: "🐧", + aliases: ["penguin"], + tags: [], + category: "Animals & Nature", + description: "penguin", + unicode_version: "6.0", + }, + { + emoji: "🕊️", + aliases: ["dove"], + tags: ["peace"], + category: "Animals & Nature", + description: "dove", + unicode_version: "7.0", + }, + { + emoji: "🦅", + aliases: ["eagle"], + tags: [], + category: "Animals & Nature", + description: "eagle", + unicode_version: "9.0", + }, + { + emoji: "🦆", + aliases: ["duck"], + tags: [], + category: "Animals & Nature", + description: "duck", + unicode_version: "9.0", + }, + { + emoji: "🦢", + aliases: ["swan"], + tags: [], + category: "Animals & Nature", + description: "swan", + unicode_version: "11.0", + }, + { + emoji: "🦉", + aliases: ["owl"], + tags: [], + category: "Animals & Nature", + description: "owl", + unicode_version: "9.0", + }, + { + emoji: "🦤", + aliases: ["dodo"], + tags: [], + category: "Animals & Nature", + description: "dodo", + unicode_version: "13.0", + }, + { + emoji: "🪶", + aliases: ["feather"], + tags: [], + category: "Animals & Nature", + description: "feather", + unicode_version: "13.0", + }, + { + emoji: "🦩", + aliases: ["flamingo"], + tags: [], + category: "Animals & Nature", + description: "flamingo", + unicode_version: "12.0", + }, + { + emoji: "🦚", + aliases: ["peacock"], + tags: [], + category: "Animals & Nature", + description: "peacock", + unicode_version: "11.0", + }, + { + emoji: "🦜", + aliases: ["parrot"], + tags: [], + category: "Animals & Nature", + description: "parrot", + unicode_version: "11.0", + }, + { + emoji: "🐸", + aliases: ["frog"], + tags: [], + category: "Animals & Nature", + description: "frog", + unicode_version: "6.0", + }, + { + emoji: "🐊", + aliases: ["crocodile"], + tags: [], + category: "Animals & Nature", + description: "crocodile", + unicode_version: "6.0", + }, + { + emoji: "🐢", + aliases: ["turtle"], + tags: ["slow"], + category: "Animals & Nature", + description: "turtle", + unicode_version: "6.0", + }, + { + emoji: "🦎", + aliases: ["lizard"], + tags: [], + category: "Animals & Nature", + description: "lizard", + unicode_version: "9.0", + }, + { + emoji: "🐍", + aliases: ["snake"], + tags: [], + category: "Animals & Nature", + description: "snake", + unicode_version: "6.0", + }, + { + emoji: "🐲", + aliases: ["dragon_face"], + tags: [], + category: "Animals & Nature", + description: "dragon face", + unicode_version: "6.0", + }, + { + emoji: "🐉", + aliases: ["dragon"], + tags: [], + category: "Animals & Nature", + description: "dragon", + unicode_version: "6.0", + }, + { + emoji: "🦕", + aliases: ["sauropod"], + tags: ["dinosaur"], + category: "Animals & Nature", + description: "sauropod", + unicode_version: "11.0", + }, + { + emoji: "🦖", + aliases: ["t-rex"], + tags: ["dinosaur"], + category: "Animals & Nature", + description: "T-Rex", + unicode_version: "11.0", + }, + { + emoji: "🐳", + aliases: ["whale"], + tags: ["sea"], + category: "Animals & Nature", + description: "spouting whale", + unicode_version: "6.0", + }, + { + emoji: "🐋", + aliases: ["whale2"], + tags: [], + category: "Animals & Nature", + description: "whale", + unicode_version: "6.0", + }, + { + emoji: "🐬", + aliases: ["dolphin", "flipper"], + tags: [], + category: "Animals & Nature", + description: "dolphin", + unicode_version: "6.0", + }, + { + emoji: "🦭", + aliases: ["seal"], + tags: [], + category: "Animals & Nature", + description: "seal", + unicode_version: "13.0", + }, + { + emoji: "🐟", + aliases: ["fish"], + tags: [], + category: "Animals & Nature", + description: "fish", + unicode_version: "6.0", + }, + { + emoji: "🐠", + aliases: ["tropical_fish"], + tags: [], + category: "Animals & Nature", + description: "tropical fish", + unicode_version: "6.0", + }, + { + emoji: "🐡", + aliases: ["blowfish"], + tags: [], + category: "Animals & Nature", + description: "blowfish", + unicode_version: "6.0", + }, + { + emoji: "🦈", + aliases: ["shark"], + tags: [], + category: "Animals & Nature", + description: "shark", + unicode_version: "9.0", + }, + { + emoji: "🐙", + aliases: ["octopus"], + tags: [], + category: "Animals & Nature", + description: "octopus", + unicode_version: "6.0", + }, + { + emoji: "🐚", + aliases: ["shell"], + tags: ["sea", "beach"], + category: "Animals & Nature", + description: "spiral shell", + unicode_version: "6.0", + }, + { + emoji: "🐌", + aliases: ["snail"], + tags: ["slow"], + category: "Animals & Nature", + description: "snail", + unicode_version: "6.0", + }, + { + emoji: "🦋", + aliases: ["butterfly"], + tags: [], + category: "Animals & Nature", + description: "butterfly", + unicode_version: "9.0", + }, + { + emoji: "🐛", + aliases: ["bug"], + tags: [], + category: "Animals & Nature", + description: "bug", + unicode_version: "6.0", + }, + { + emoji: "🐜", + aliases: ["ant"], + tags: [], + category: "Animals & Nature", + description: "ant", + unicode_version: "6.0", + }, + { + emoji: "🐝", + aliases: ["bee", "honeybee"], + tags: [], + category: "Animals & Nature", + description: "honeybee", + unicode_version: "6.0", + }, + { + emoji: "🪲", + aliases: ["beetle"], + tags: [], + category: "Animals & Nature", + description: "beetle", + unicode_version: "13.0", + }, + { + emoji: "🐞", + aliases: ["lady_beetle"], + tags: ["bug"], + category: "Animals & Nature", + description: "lady beetle", + unicode_version: "6.0", + }, + { + emoji: "🦗", + aliases: ["cricket"], + tags: [], + category: "Animals & Nature", + description: "cricket", + unicode_version: "11.0", + }, + { + emoji: "🪳", + aliases: ["cockroach"], + tags: [], + category: "Animals & Nature", + description: "cockroach", + unicode_version: "13.0", + }, + { + emoji: "🕷️", + aliases: ["spider"], + tags: [], + category: "Animals & Nature", + description: "spider", + unicode_version: "7.0", + }, + { + emoji: "🕸️", + aliases: ["spider_web"], + tags: [], + category: "Animals & Nature", + description: "spider web", + unicode_version: "7.0", + }, + { + emoji: "🦂", + aliases: ["scorpion"], + tags: [], + category: "Animals & Nature", + description: "scorpion", + unicode_version: "8.0", + }, + { + emoji: "🦟", + aliases: ["mosquito"], + tags: [], + category: "Animals & Nature", + description: "mosquito", + unicode_version: "11.0", + }, + { + emoji: "🪰", + aliases: ["fly"], + tags: [], + category: "Animals & Nature", + description: "fly", + unicode_version: "13.0", + }, + { + emoji: "🪱", + aliases: ["worm"], + tags: [], + category: "Animals & Nature", + description: "worm", + unicode_version: "13.0", + }, + { + emoji: "🦠", + aliases: ["microbe"], + tags: ["germ"], + category: "Animals & Nature", + description: "microbe", + unicode_version: "11.0", + }, + { + emoji: "💐", + aliases: ["bouquet"], + tags: ["flowers"], + category: "Animals & Nature", + description: "bouquet", + unicode_version: "6.0", + }, + { + emoji: "🌸", + aliases: ["cherry_blossom"], + tags: ["flower", "spring"], + category: "Animals & Nature", + description: "cherry blossom", + unicode_version: "6.0", + }, + { + emoji: "💮", + aliases: ["white_flower"], + tags: [], + category: "Animals & Nature", + description: "white flower", + unicode_version: "6.0", + }, + { + emoji: "🏵️", + aliases: ["rosette"], + tags: [], + category: "Animals & Nature", + description: "rosette", + unicode_version: "7.0", + }, + { + emoji: "🌹", + aliases: ["rose"], + tags: ["flower"], + category: "Animals & Nature", + description: "rose", + unicode_version: "6.0", + }, + { + emoji: "🥀", + aliases: ["wilted_flower"], + tags: [], + category: "Animals & Nature", + description: "wilted flower", + unicode_version: "9.0", + }, + { + emoji: "🌺", + aliases: ["hibiscus"], + tags: [], + category: "Animals & Nature", + description: "hibiscus", + unicode_version: "6.0", + }, + { + emoji: "🌻", + aliases: ["sunflower"], + tags: [], + category: "Animals & Nature", + description: "sunflower", + unicode_version: "6.0", + }, + { + emoji: "🌼", + aliases: ["blossom"], + tags: [], + category: "Animals & Nature", + description: "blossom", + unicode_version: "6.0", + }, + { + emoji: "🌷", + aliases: ["tulip"], + tags: ["flower"], + category: "Animals & Nature", + description: "tulip", + unicode_version: "6.0", + }, + { + emoji: "🌱", + aliases: ["seedling"], + tags: ["plant"], + category: "Animals & Nature", + description: "seedling", + unicode_version: "6.0", + }, + { + emoji: "🪴", + aliases: ["potted_plant"], + tags: [], + category: "Animals & Nature", + description: "potted plant", + unicode_version: "13.0", + }, + { + emoji: "🌲", + aliases: ["evergreen_tree"], + tags: ["wood"], + category: "Animals & Nature", + description: "evergreen tree", + unicode_version: "6.0", + }, + { + emoji: "🌳", + aliases: ["deciduous_tree"], + tags: ["wood"], + category: "Animals & Nature", + description: "deciduous tree", + unicode_version: "6.0", + }, + { + emoji: "🌴", + aliases: ["palm_tree"], + tags: [], + category: "Animals & Nature", + description: "palm tree", + unicode_version: "6.0", + }, + { + emoji: "🌵", + aliases: ["cactus"], + tags: [], + category: "Animals & Nature", + description: "cactus", + unicode_version: "6.0", + }, + { + emoji: "🌾", + aliases: ["ear_of_rice"], + tags: [], + category: "Animals & Nature", + description: "sheaf of rice", + unicode_version: "6.0", + }, + { + emoji: "🌿", + aliases: ["herb"], + tags: [], + category: "Animals & Nature", + description: "herb", + unicode_version: "6.0", + }, + { + emoji: "☘️", + aliases: ["shamrock"], + tags: [], + category: "Animals & Nature", + description: "shamrock", + unicode_version: "4.1", + }, + { + emoji: "🍀", + aliases: ["four_leaf_clover"], + tags: ["luck"], + category: "Animals & Nature", + description: "four leaf clover", + unicode_version: "6.0", + }, + { + emoji: "🍁", + aliases: ["maple_leaf"], + tags: ["canada"], + category: "Animals & Nature", + description: "maple leaf", + unicode_version: "6.0", + }, + { + emoji: "🍂", + aliases: ["fallen_leaf"], + tags: ["autumn"], + category: "Animals & Nature", + description: "fallen leaf", + unicode_version: "6.0", + }, + { + emoji: "🍃", + aliases: ["leaves"], + tags: ["leaf"], + category: "Animals & Nature", + description: "leaf fluttering in wind", + unicode_version: "6.0", + }, + { + emoji: "🍇", + aliases: ["grapes"], + tags: [], + category: "Food & Drink", + description: "grapes", + unicode_version: "6.0", + }, + { + emoji: "🍈", + aliases: ["melon"], + tags: [], + category: "Food & Drink", + description: "melon", + unicode_version: "6.0", + }, + { + emoji: "🍉", + aliases: ["watermelon"], + tags: [], + category: "Food & Drink", + description: "watermelon", + unicode_version: "6.0", + }, + { + emoji: "🍊", + aliases: ["tangerine", "orange", "mandarin"], + tags: [], + category: "Food & Drink", + description: "tangerine", + unicode_version: "6.0", + }, + { + emoji: "🍋", + aliases: ["lemon"], + tags: [], + category: "Food & Drink", + description: "lemon", + unicode_version: "6.0", + }, + { + emoji: "🍌", + aliases: ["banana"], + tags: ["fruit"], + category: "Food & Drink", + description: "banana", + unicode_version: "6.0", + }, + { + emoji: "🍍", + aliases: ["pineapple"], + tags: [], + category: "Food & Drink", + description: "pineapple", + unicode_version: "6.0", + }, + { + emoji: "🥭", + aliases: ["mango"], + tags: [], + category: "Food & Drink", + description: "mango", + unicode_version: "11.0", + }, + { + emoji: "🍎", + aliases: ["apple"], + tags: [], + category: "Food & Drink", + description: "red apple", + unicode_version: "6.0", + }, + { + emoji: "🍏", + aliases: ["green_apple"], + tags: ["fruit"], + category: "Food & Drink", + description: "green apple", + unicode_version: "6.0", + }, + { + emoji: "🍐", + aliases: ["pear"], + tags: [], + category: "Food & Drink", + description: "pear", + unicode_version: "6.0", + }, + { + emoji: "🍑", + aliases: ["peach"], + tags: [], + category: "Food & Drink", + description: "peach", + unicode_version: "6.0", + }, + { + emoji: "🍒", + aliases: ["cherries"], + tags: ["fruit"], + category: "Food & Drink", + description: "cherries", + unicode_version: "6.0", + }, + { + emoji: "🍓", + aliases: ["strawberry"], + tags: ["fruit"], + category: "Food & Drink", + description: "strawberry", + unicode_version: "6.0", + }, + { + emoji: "🫐", + aliases: ["blueberries"], + tags: [], + category: "Food & Drink", + description: "blueberries", + unicode_version: "13.0", + }, + { + emoji: "🥝", + aliases: ["kiwi_fruit"], + tags: [], + category: "Food & Drink", + description: "kiwi fruit", + unicode_version: "9.0", + }, + { + emoji: "🍅", + aliases: ["tomato"], + tags: [], + category: "Food & Drink", + description: "tomato", + unicode_version: "6.0", + }, + { + emoji: "🫒", + aliases: ["olive"], + tags: [], + category: "Food & Drink", + description: "olive", + unicode_version: "13.0", + }, + { + emoji: "🥥", + aliases: ["coconut"], + tags: [], + category: "Food & Drink", + description: "coconut", + unicode_version: "11.0", + }, + { + emoji: "🥑", + aliases: ["avocado"], + tags: [], + category: "Food & Drink", + description: "avocado", + unicode_version: "9.0", + }, + { + emoji: "🍆", + aliases: ["eggplant"], + tags: ["aubergine"], + category: "Food & Drink", + description: "eggplant", + unicode_version: "6.0", + }, + { + emoji: "🥔", + aliases: ["potato"], + tags: [], + category: "Food & Drink", + description: "potato", + unicode_version: "9.0", + }, + { + emoji: "🥕", + aliases: ["carrot"], + tags: [], + category: "Food & Drink", + description: "carrot", + unicode_version: "9.0", + }, + { + emoji: "🌽", + aliases: ["corn"], + tags: [], + category: "Food & Drink", + description: "ear of corn", + unicode_version: "6.0", + }, + { + emoji: "🌶️", + aliases: ["hot_pepper"], + tags: ["spicy"], + category: "Food & Drink", + description: "hot pepper", + unicode_version: "7.0", + }, + { + emoji: "🫑", + aliases: ["bell_pepper"], + tags: [], + category: "Food & Drink", + description: "bell pepper", + unicode_version: "13.0", + }, + { + emoji: "🥒", + aliases: ["cucumber"], + tags: [], + category: "Food & Drink", + description: "cucumber", + unicode_version: "9.0", + }, + { + emoji: "🥬", + aliases: ["leafy_green"], + tags: [], + category: "Food & Drink", + description: "leafy green", + unicode_version: "11.0", + }, + { + emoji: "🥦", + aliases: ["broccoli"], + tags: [], + category: "Food & Drink", + description: "broccoli", + unicode_version: "11.0", + }, + { + emoji: "🧄", + aliases: ["garlic"], + tags: [], + category: "Food & Drink", + description: "garlic", + unicode_version: "12.0", + }, + { + emoji: "🧅", + aliases: ["onion"], + tags: [], + category: "Food & Drink", + description: "onion", + unicode_version: "12.0", + }, + { + emoji: "🍄", + aliases: ["mushroom"], + tags: [], + category: "Food & Drink", + description: "mushroom", + unicode_version: "6.0", + }, + { + emoji: "🥜", + aliases: ["peanuts"], + tags: [], + category: "Food & Drink", + description: "peanuts", + unicode_version: "9.0", + }, + { + emoji: "🌰", + aliases: ["chestnut"], + tags: [], + category: "Food & Drink", + description: "chestnut", + unicode_version: "6.0", + }, + { + emoji: "🍞", + aliases: ["bread"], + tags: ["toast"], + category: "Food & Drink", + description: "bread", + unicode_version: "6.0", + }, + { + emoji: "🥐", + aliases: ["croissant"], + tags: [], + category: "Food & Drink", + description: "croissant", + unicode_version: "9.0", + }, + { + emoji: "🥖", + aliases: ["baguette_bread"], + tags: [], + category: "Food & Drink", + description: "baguette bread", + unicode_version: "9.0", + }, + { + emoji: "🫓", + aliases: ["flatbread"], + tags: [], + category: "Food & Drink", + description: "flatbread", + unicode_version: "13.0", + }, + { + emoji: "🥨", + aliases: ["pretzel"], + tags: [], + category: "Food & Drink", + description: "pretzel", + unicode_version: "11.0", + }, + { + emoji: "🥯", + aliases: ["bagel"], + tags: [], + category: "Food & Drink", + description: "bagel", + unicode_version: "11.0", + }, + { + emoji: "🥞", + aliases: ["pancakes"], + tags: [], + category: "Food & Drink", + description: "pancakes", + unicode_version: "9.0", + }, + { + emoji: "🧇", + aliases: ["waffle"], + tags: [], + category: "Food & Drink", + description: "waffle", + unicode_version: "12.0", + }, + { + emoji: "🧀", + aliases: ["cheese"], + tags: [], + category: "Food & Drink", + description: "cheese wedge", + unicode_version: "8.0", + }, + { + emoji: "🍖", + aliases: ["meat_on_bone"], + tags: [], + category: "Food & Drink", + description: "meat on bone", + unicode_version: "6.0", + }, + { + emoji: "🍗", + aliases: ["poultry_leg"], + tags: ["meat", "chicken"], + category: "Food & Drink", + description: "poultry leg", + unicode_version: "6.0", + }, + { + emoji: "🥩", + aliases: ["cut_of_meat"], + tags: [], + category: "Food & Drink", + description: "cut of meat", + unicode_version: "11.0", + }, + { + emoji: "🥓", + aliases: ["bacon"], + tags: [], + category: "Food & Drink", + description: "bacon", + unicode_version: "9.0", + }, + { + emoji: "🍔", + aliases: ["hamburger"], + tags: ["burger"], + category: "Food & Drink", + description: "hamburger", + unicode_version: "6.0", + }, + { + emoji: "🍟", + aliases: ["fries"], + tags: [], + category: "Food & Drink", + description: "french fries", + unicode_version: "6.0", + }, + { + emoji: "🍕", + aliases: ["pizza"], + tags: [], + category: "Food & Drink", + description: "pizza", + unicode_version: "6.0", + }, + { + emoji: "🌭", + aliases: ["hotdog"], + tags: [], + category: "Food & Drink", + description: "hot dog", + unicode_version: "8.0", + }, + { + emoji: "🥪", + aliases: ["sandwich"], + tags: [], + category: "Food & Drink", + description: "sandwich", + unicode_version: "11.0", + }, + { + emoji: "🌮", + aliases: ["taco"], + tags: [], + category: "Food & Drink", + description: "taco", + unicode_version: "8.0", + }, + { + emoji: "🌯", + aliases: ["burrito"], + tags: [], + category: "Food & Drink", + description: "burrito", + unicode_version: "8.0", + }, + { + emoji: "🫔", + aliases: ["tamale"], + tags: [], + category: "Food & Drink", + description: "tamale", + unicode_version: "13.0", + }, + { + emoji: "🥙", + aliases: ["stuffed_flatbread"], + tags: [], + category: "Food & Drink", + description: "stuffed flatbread", + unicode_version: "9.0", + }, + { + emoji: "🧆", + aliases: ["falafel"], + tags: [], + category: "Food & Drink", + description: "falafel", + unicode_version: "12.0", + }, + { + emoji: "🥚", + aliases: ["egg"], + tags: [], + category: "Food & Drink", + description: "egg", + unicode_version: "9.0", + }, + { + emoji: "🍳", + aliases: ["fried_egg"], + tags: ["breakfast"], + category: "Food & Drink", + description: "cooking", + unicode_version: "6.0", + }, + { + emoji: "🥘", + aliases: ["shallow_pan_of_food"], + tags: ["paella", "curry"], + category: "Food & Drink", + description: "shallow pan of food", + unicode_version: "", + }, + { + emoji: "🍲", + aliases: ["stew"], + tags: [], + category: "Food & Drink", + description: "pot of food", + unicode_version: "6.0", + }, + { + emoji: "🫕", + aliases: ["fondue"], + tags: [], + category: "Food & Drink", + description: "fondue", + unicode_version: "13.0", + }, + { + emoji: "🥣", + aliases: ["bowl_with_spoon"], + tags: [], + category: "Food & Drink", + description: "bowl with spoon", + unicode_version: "11.0", + }, + { + emoji: "🥗", + aliases: ["green_salad"], + tags: [], + category: "Food & Drink", + description: "green salad", + unicode_version: "9.0", + }, + { + emoji: "🍿", + aliases: ["popcorn"], + tags: [], + category: "Food & Drink", + description: "popcorn", + unicode_version: "8.0", + }, + { + emoji: "🧈", + aliases: ["butter"], + tags: [], + category: "Food & Drink", + description: "butter", + unicode_version: "12.0", + }, + { + emoji: "🧂", + aliases: ["salt"], + tags: [], + category: "Food & Drink", + description: "salt", + unicode_version: "11.0", + }, + { + emoji: "🥫", + aliases: ["canned_food"], + tags: [], + category: "Food & Drink", + description: "canned food", + unicode_version: "11.0", + }, + { + emoji: "🍱", + aliases: ["bento"], + tags: [], + category: "Food & Drink", + description: "bento box", + unicode_version: "6.0", + }, + { + emoji: "🍘", + aliases: ["rice_cracker"], + tags: [], + category: "Food & Drink", + description: "rice cracker", + unicode_version: "6.0", + }, + { + emoji: "🍙", + aliases: ["rice_ball"], + tags: [], + category: "Food & Drink", + description: "rice ball", + unicode_version: "6.0", + }, + { + emoji: "🍚", + aliases: ["rice"], + tags: [], + category: "Food & Drink", + description: "cooked rice", + unicode_version: "6.0", + }, + { + emoji: "🍛", + aliases: ["curry"], + tags: [], + category: "Food & Drink", + description: "curry rice", + unicode_version: "6.0", + }, + { + emoji: "🍜", + aliases: ["ramen"], + tags: ["noodle"], + category: "Food & Drink", + description: "steaming bowl", + unicode_version: "6.0", + }, + { + emoji: "🍝", + aliases: ["spaghetti"], + tags: ["pasta"], + category: "Food & Drink", + description: "spaghetti", + unicode_version: "6.0", + }, + { + emoji: "🍠", + aliases: ["sweet_potato"], + tags: [], + category: "Food & Drink", + description: "roasted sweet potato", + unicode_version: "6.0", + }, + { + emoji: "🍢", + aliases: ["oden"], + tags: [], + category: "Food & Drink", + description: "oden", + unicode_version: "6.0", + }, + { + emoji: "🍣", + aliases: ["sushi"], + tags: [], + category: "Food & Drink", + description: "sushi", + unicode_version: "6.0", + }, + { + emoji: "🍤", + aliases: ["fried_shrimp"], + tags: ["tempura"], + category: "Food & Drink", + description: "fried shrimp", + unicode_version: "6.0", + }, + { + emoji: "🍥", + aliases: ["fish_cake"], + tags: [], + category: "Food & Drink", + description: "fish cake with swirl", + unicode_version: "6.0", + }, + { + emoji: "🥮", + aliases: ["moon_cake"], + tags: [], + category: "Food & Drink", + description: "moon cake", + unicode_version: "11.0", + }, + { + emoji: "🍡", + aliases: ["dango"], + tags: [], + category: "Food & Drink", + description: "dango", + unicode_version: "6.0", + }, + { + emoji: "🥟", + aliases: ["dumpling"], + tags: [], + category: "Food & Drink", + description: "dumpling", + unicode_version: "11.0", + }, + { + emoji: "🥠", + aliases: ["fortune_cookie"], + tags: [], + category: "Food & Drink", + description: "fortune cookie", + unicode_version: "11.0", + }, + { + emoji: "🥡", + aliases: ["takeout_box"], + tags: [], + category: "Food & Drink", + description: "takeout box", + unicode_version: "11.0", + }, + { + emoji: "🦀", + aliases: ["crab"], + tags: [], + category: "Food & Drink", + description: "crab", + unicode_version: "8.0", + }, + { + emoji: "🦞", + aliases: ["lobster"], + tags: [], + category: "Food & Drink", + description: "lobster", + unicode_version: "11.0", + }, + { + emoji: "🦐", + aliases: ["shrimp"], + tags: [], + category: "Food & Drink", + description: "shrimp", + unicode_version: "9.0", + }, + { + emoji: "🦑", + aliases: ["squid"], + tags: [], + category: "Food & Drink", + description: "squid", + unicode_version: "9.0", + }, + { + emoji: "🦪", + aliases: ["oyster"], + tags: [], + category: "Food & Drink", + description: "oyster", + unicode_version: "12.0", + }, + { + emoji: "🍦", + aliases: ["icecream"], + tags: [], + category: "Food & Drink", + description: "soft ice cream", + unicode_version: "6.0", + }, + { + emoji: "🍧", + aliases: ["shaved_ice"], + tags: [], + category: "Food & Drink", + description: "shaved ice", + unicode_version: "6.0", + }, + { + emoji: "🍨", + aliases: ["ice_cream"], + tags: [], + category: "Food & Drink", + description: "ice cream", + unicode_version: "6.0", + }, + { + emoji: "🍩", + aliases: ["doughnut"], + tags: [], + category: "Food & Drink", + description: "doughnut", + unicode_version: "6.0", + }, + { + emoji: "🍪", + aliases: ["cookie"], + tags: [], + category: "Food & Drink", + description: "cookie", + unicode_version: "6.0", + }, + { + emoji: "🎂", + aliases: ["birthday"], + tags: ["party"], + category: "Food & Drink", + description: "birthday cake", + unicode_version: "6.0", + }, + { + emoji: "🍰", + aliases: ["cake"], + tags: ["dessert"], + category: "Food & Drink", + description: "shortcake", + unicode_version: "6.0", + }, + { + emoji: "🧁", + aliases: ["cupcake"], + tags: [], + category: "Food & Drink", + description: "cupcake", + unicode_version: "11.0", + }, + { + emoji: "🥧", + aliases: ["pie"], + tags: [], + category: "Food & Drink", + description: "pie", + unicode_version: "11.0", + }, + { + emoji: "🍫", + aliases: ["chocolate_bar"], + tags: [], + category: "Food & Drink", + description: "chocolate bar", + unicode_version: "6.0", + }, + { + emoji: "🍬", + aliases: ["candy"], + tags: ["sweet"], + category: "Food & Drink", + description: "candy", + unicode_version: "6.0", + }, + { + emoji: "🍭", + aliases: ["lollipop"], + tags: [], + category: "Food & Drink", + description: "lollipop", + unicode_version: "6.0", + }, + { + emoji: "🍮", + aliases: ["custard"], + tags: [], + category: "Food & Drink", + description: "custard", + unicode_version: "6.0", + }, + { + emoji: "🍯", + aliases: ["honey_pot"], + tags: [], + category: "Food & Drink", + description: "honey pot", + unicode_version: "6.0", + }, + { + emoji: "🍼", + aliases: ["baby_bottle"], + tags: ["milk"], + category: "Food & Drink", + description: "baby bottle", + unicode_version: "6.0", + }, + { + emoji: "🥛", + aliases: ["milk_glass"], + tags: [], + category: "Food & Drink", + description: "glass of milk", + unicode_version: "9.0", + }, + { + emoji: "☕", + aliases: ["coffee"], + tags: ["cafe", "espresso"], + category: "Food & Drink", + description: "hot beverage", + unicode_version: "4.0", + }, + { + emoji: "🫖", + aliases: ["teapot"], + tags: [], + category: "Food & Drink", + description: "teapot", + unicode_version: "13.0", + }, + { + emoji: "🍵", + aliases: ["tea"], + tags: ["green", "breakfast"], + category: "Food & Drink", + description: "teacup without handle", + unicode_version: "6.0", + }, + { + emoji: "🍶", + aliases: ["sake"], + tags: [], + category: "Food & Drink", + description: "sake", + unicode_version: "6.0", + }, + { + emoji: "🍾", + aliases: ["champagne"], + tags: ["bottle", "bubbly", "celebration"], + category: "Food & Drink", + description: "bottle with popping cork", + unicode_version: "8.0", + }, + { + emoji: "🍷", + aliases: ["wine_glass"], + tags: [], + category: "Food & Drink", + description: "wine glass", + unicode_version: "6.0", + }, + { + emoji: "🍸", + aliases: ["cocktail"], + tags: ["drink"], + category: "Food & Drink", + description: "cocktail glass", + unicode_version: "6.0", + }, + { + emoji: "🍹", + aliases: ["tropical_drink"], + tags: ["summer", "vacation"], + category: "Food & Drink", + description: "tropical drink", + unicode_version: "6.0", + }, + { + emoji: "🍺", + aliases: ["beer"], + tags: ["drink"], + category: "Food & Drink", + description: "beer mug", + unicode_version: "6.0", + }, + { + emoji: "🍻", + aliases: ["beers"], + tags: ["drinks"], + category: "Food & Drink", + description: "clinking beer mugs", + unicode_version: "6.0", + }, + { + emoji: "🥂", + aliases: ["clinking_glasses"], + tags: ["cheers", "toast"], + category: "Food & Drink", + description: "clinking glasses", + unicode_version: "9.0", + }, + { + emoji: "🥃", + aliases: ["tumbler_glass"], + tags: ["whisky"], + category: "Food & Drink", + description: "tumbler glass", + unicode_version: "9.0", + }, + { + emoji: "🥤", + aliases: ["cup_with_straw"], + tags: [], + category: "Food & Drink", + description: "cup with straw", + unicode_version: "11.0", + }, + { + emoji: "🧋", + aliases: ["bubble_tea"], + tags: [], + category: "Food & Drink", + description: "bubble tea", + unicode_version: "13.0", + }, + { + emoji: "🧃", + aliases: ["beverage_box"], + tags: [], + category: "Food & Drink", + description: "beverage box", + unicode_version: "12.0", + }, + { + emoji: "🧉", + aliases: ["mate"], + tags: [], + category: "Food & Drink", + description: "mate", + unicode_version: "12.0", + }, + { + emoji: "🧊", + aliases: ["ice_cube"], + tags: [], + category: "Food & Drink", + description: "ice", + unicode_version: "12.0", + }, + { + emoji: "🥢", + aliases: ["chopsticks"], + tags: [], + category: "Food & Drink", + description: "chopsticks", + unicode_version: "11.0", + }, + { + emoji: "🍽️", + aliases: ["plate_with_cutlery"], + tags: ["dining", "dinner"], + category: "Food & Drink", + description: "fork and knife with plate", + unicode_version: "7.0", + }, + { + emoji: "🍴", + aliases: ["fork_and_knife"], + tags: ["cutlery"], + category: "Food & Drink", + description: "fork and knife", + unicode_version: "6.0", + }, + { + emoji: "🥄", + aliases: ["spoon"], + tags: [], + category: "Food & Drink", + description: "spoon", + unicode_version: "9.0", + }, + { + emoji: "🔪", + aliases: ["hocho", "knife"], + tags: ["cut", "chop"], + category: "Food & Drink", + description: "kitchen knife", + unicode_version: "6.0", + }, + { + emoji: "🏺", + aliases: ["amphora"], + tags: [], + category: "Food & Drink", + description: "amphora", + unicode_version: "8.0", + }, + { + emoji: "🌍", + aliases: ["earth_africa"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Europe-Africa", + unicode_version: "6.0", + }, + { + emoji: "🌎", + aliases: ["earth_americas"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Americas", + unicode_version: "6.0", + }, + { + emoji: "🌏", + aliases: ["earth_asia"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Asia-Australia", + unicode_version: "6.0", + }, + { + emoji: "🌐", + aliases: ["globe_with_meridians"], + tags: ["world", "global", "international"], + category: "Travel & Places", + description: "globe with meridians", + unicode_version: "6.0", + }, + { + emoji: "🗺️", + aliases: ["world_map"], + tags: ["travel"], + category: "Travel & Places", + description: "world map", + unicode_version: "7.0", + }, + { + emoji: "🗾", + aliases: ["japan"], + tags: [], + category: "Travel & Places", + description: "map of Japan", + unicode_version: "6.0", + }, + { + emoji: "🧭", + aliases: ["compass"], + tags: [], + category: "Travel & Places", + description: "compass", + unicode_version: "11.0", + }, + { + emoji: "🏔️", + aliases: ["mountain_snow"], + tags: [], + category: "Travel & Places", + description: "snow-capped mountain", + unicode_version: "7.0", + }, + { + emoji: "⛰️", + aliases: ["mountain"], + tags: [], + category: "Travel & Places", + description: "mountain", + unicode_version: "5.2", + }, + { + emoji: "🌋", + aliases: ["volcano"], + tags: [], + category: "Travel & Places", + description: "volcano", + unicode_version: "6.0", + }, + { + emoji: "🗻", + aliases: ["mount_fuji"], + tags: [], + category: "Travel & Places", + description: "mount fuji", + unicode_version: "6.0", + }, + { + emoji: "🏕️", + aliases: ["camping"], + tags: [], + category: "Travel & Places", + description: "camping", + unicode_version: "7.0", + }, + { + emoji: "🏖️", + aliases: ["beach_umbrella"], + tags: [], + category: "Travel & Places", + description: "beach with umbrella", + unicode_version: "7.0", + }, + { + emoji: "🏜️", + aliases: ["desert"], + tags: [], + category: "Travel & Places", + description: "desert", + unicode_version: "7.0", + }, + { + emoji: "🏝️", + aliases: ["desert_island"], + tags: [], + category: "Travel & Places", + description: "desert island", + unicode_version: "7.0", + }, + { + emoji: "🏞️", + aliases: ["national_park"], + tags: [], + category: "Travel & Places", + description: "national park", + unicode_version: "7.0", + }, + { + emoji: "🏟️", + aliases: ["stadium"], + tags: [], + category: "Travel & Places", + description: "stadium", + unicode_version: "7.0", + }, + { + emoji: "🏛️", + aliases: ["classical_building"], + tags: [], + category: "Travel & Places", + description: "classical building", + unicode_version: "7.0", + }, + { + emoji: "🏗️", + aliases: ["building_construction"], + tags: [], + category: "Travel & Places", + description: "building construction", + unicode_version: "7.0", + }, + { + emoji: "🧱", + aliases: ["bricks"], + tags: [], + category: "Travel & Places", + description: "brick", + unicode_version: "11.0", + }, + { + emoji: "🪨", + aliases: ["rock"], + tags: [], + category: "Travel & Places", + description: "rock", + unicode_version: "13.0", + }, + { + emoji: "🪵", + aliases: ["wood"], + tags: [], + category: "Travel & Places", + description: "wood", + unicode_version: "13.0", + }, + { + emoji: "🛖", + aliases: ["hut"], + tags: [], + category: "Travel & Places", + description: "hut", + unicode_version: "13.0", + }, + { + emoji: "🏘️", + aliases: ["houses"], + tags: [], + category: "Travel & Places", + description: "houses", + unicode_version: "7.0", + }, + { + emoji: "🏚️", + aliases: ["derelict_house"], + tags: [], + category: "Travel & Places", + description: "derelict house", + unicode_version: "7.0", + }, + { + emoji: "🏠", + aliases: ["house"], + tags: [], + category: "Travel & Places", + description: "house", + unicode_version: "6.0", + }, + { + emoji: "🏡", + aliases: ["house_with_garden"], + tags: [], + category: "Travel & Places", + description: "house with garden", + unicode_version: "6.0", + }, + { + emoji: "🏢", + aliases: ["office"], + tags: [], + category: "Travel & Places", + description: "office building", + unicode_version: "6.0", + }, + { + emoji: "🏣", + aliases: ["post_office"], + tags: [], + category: "Travel & Places", + description: "Japanese post office", + unicode_version: "6.0", + }, + { + emoji: "🏤", + aliases: ["european_post_office"], + tags: [], + category: "Travel & Places", + description: "post office", + unicode_version: "6.0", + }, + { + emoji: "🏥", + aliases: ["hospital"], + tags: [], + category: "Travel & Places", + description: "hospital", + unicode_version: "6.0", + }, + { + emoji: "🏦", + aliases: ["bank"], + tags: [], + category: "Travel & Places", + description: "bank", + unicode_version: "6.0", + }, + { + emoji: "🏨", + aliases: ["hotel"], + tags: [], + category: "Travel & Places", + description: "hotel", + unicode_version: "6.0", + }, + { + emoji: "🏩", + aliases: ["love_hotel"], + tags: [], + category: "Travel & Places", + description: "love hotel", + unicode_version: "6.0", + }, + { + emoji: "🏪", + aliases: ["convenience_store"], + tags: [], + category: "Travel & Places", + description: "convenience store", + unicode_version: "6.0", + }, + { + emoji: "🏫", + aliases: ["school"], + tags: [], + category: "Travel & Places", + description: "school", + unicode_version: "6.0", + }, + { + emoji: "🏬", + aliases: ["department_store"], + tags: [], + category: "Travel & Places", + description: "department store", + unicode_version: "6.0", + }, + { + emoji: "🏭", + aliases: ["factory"], + tags: [], + category: "Travel & Places", + description: "factory", + unicode_version: "6.0", + }, + { + emoji: "🏯", + aliases: ["japanese_castle"], + tags: [], + category: "Travel & Places", + description: "Japanese castle", + unicode_version: "6.0", + }, + { + emoji: "🏰", + aliases: ["european_castle"], + tags: [], + category: "Travel & Places", + description: "castle", + unicode_version: "6.0", + }, + { + emoji: "💒", + aliases: ["wedding"], + tags: ["marriage"], + category: "Travel & Places", + description: "wedding", + unicode_version: "6.0", + }, + { + emoji: "🗼", + aliases: ["tokyo_tower"], + tags: [], + category: "Travel & Places", + description: "Tokyo tower", + unicode_version: "6.0", + }, + { + emoji: "🗽", + aliases: ["statue_of_liberty"], + tags: [], + category: "Travel & Places", + description: "Statue of Liberty", + unicode_version: "6.0", + }, + { + emoji: "⛪", + aliases: ["church"], + tags: [], + category: "Travel & Places", + description: "church", + unicode_version: "5.2", + }, + { + emoji: "🕌", + aliases: ["mosque"], + tags: [], + category: "Travel & Places", + description: "mosque", + unicode_version: "8.0", + }, + { + emoji: "🛕", + aliases: ["hindu_temple"], + tags: [], + category: "Travel & Places", + description: "hindu temple", + unicode_version: "12.0", + }, + { + emoji: "🕍", + aliases: ["synagogue"], + tags: [], + category: "Travel & Places", + description: "synagogue", + unicode_version: "8.0", + }, + { + emoji: "⛩️", + aliases: ["shinto_shrine"], + tags: [], + category: "Travel & Places", + description: "shinto shrine", + unicode_version: "5.2", + }, + { + emoji: "🕋", + aliases: ["kaaba"], + tags: [], + category: "Travel & Places", + description: "kaaba", + unicode_version: "8.0", + }, + { + emoji: "⛲", + aliases: ["fountain"], + tags: [], + category: "Travel & Places", + description: "fountain", + unicode_version: "5.2", + }, + { + emoji: "⛺", + aliases: ["tent"], + tags: ["camping"], + category: "Travel & Places", + description: "tent", + unicode_version: "5.2", + }, + { + emoji: "🌁", + aliases: ["foggy"], + tags: ["karl"], + category: "Travel & Places", + description: "foggy", + unicode_version: "6.0", + }, + { + emoji: "🌃", + aliases: ["night_with_stars"], + tags: [], + category: "Travel & Places", + description: "night with stars", + unicode_version: "6.0", + }, + { + emoji: "🏙️", + aliases: ["cityscape"], + tags: ["skyline"], + category: "Travel & Places", + description: "cityscape", + unicode_version: "7.0", + }, + { + emoji: "🌄", + aliases: ["sunrise_over_mountains"], + tags: [], + category: "Travel & Places", + description: "sunrise over mountains", + unicode_version: "6.0", + }, + { + emoji: "🌅", + aliases: ["sunrise"], + tags: [], + category: "Travel & Places", + description: "sunrise", + unicode_version: "6.0", + }, + { + emoji: "🌆", + aliases: ["city_sunset"], + tags: [], + category: "Travel & Places", + description: "cityscape at dusk", + unicode_version: "6.0", + }, + { + emoji: "🌇", + aliases: ["city_sunrise"], + tags: [], + category: "Travel & Places", + description: "sunset", + unicode_version: "6.0", + }, + { + emoji: "🌉", + aliases: ["bridge_at_night"], + tags: [], + category: "Travel & Places", + description: "bridge at night", + unicode_version: "6.0", + }, + { + emoji: "♨️", + aliases: ["hotsprings"], + tags: [], + category: "Travel & Places", + description: "hot springs", + unicode_version: "", + }, + { + emoji: "🎠", + aliases: ["carousel_horse"], + tags: [], + category: "Travel & Places", + description: "carousel horse", + unicode_version: "6.0", + }, + { + emoji: "🎡", + aliases: ["ferris_wheel"], + tags: [], + category: "Travel & Places", + description: "ferris wheel", + unicode_version: "6.0", + }, + { + emoji: "🎢", + aliases: ["roller_coaster"], + tags: [], + category: "Travel & Places", + description: "roller coaster", + unicode_version: "6.0", + }, + { + emoji: "💈", + aliases: ["barber"], + tags: [], + category: "Travel & Places", + description: "barber pole", + unicode_version: "6.0", + }, + { + emoji: "🎪", + aliases: ["circus_tent"], + tags: [], + category: "Travel & Places", + description: "circus tent", + unicode_version: "6.0", + }, + { + emoji: "🚂", + aliases: ["steam_locomotive"], + tags: ["train"], + category: "Travel & Places", + description: "locomotive", + unicode_version: "6.0", + }, + { + emoji: "🚃", + aliases: ["railway_car"], + tags: [], + category: "Travel & Places", + description: "railway car", + unicode_version: "6.0", + }, + { + emoji: "🚄", + aliases: ["bullettrain_side"], + tags: ["train"], + category: "Travel & Places", + description: "high-speed train", + unicode_version: "6.0", + }, + { + emoji: "🚅", + aliases: ["bullettrain_front"], + tags: ["train"], + category: "Travel & Places", + description: "bullet train", + unicode_version: "6.0", + }, + { + emoji: "🚆", + aliases: ["train2"], + tags: [], + category: "Travel & Places", + description: "train", + unicode_version: "6.0", + }, + { + emoji: "🚇", + aliases: ["metro"], + tags: [], + category: "Travel & Places", + description: "metro", + unicode_version: "6.0", + }, + { + emoji: "🚈", + aliases: ["light_rail"], + tags: [], + category: "Travel & Places", + description: "light rail", + unicode_version: "6.0", + }, + { + emoji: "🚉", + aliases: ["station"], + tags: [], + category: "Travel & Places", + description: "station", + unicode_version: "6.0", + }, + { + emoji: "🚊", + aliases: ["tram"], + tags: [], + category: "Travel & Places", + description: "tram", + unicode_version: "6.0", + }, + { + emoji: "🚝", + aliases: ["monorail"], + tags: [], + category: "Travel & Places", + description: "monorail", + unicode_version: "6.0", + }, + { + emoji: "🚞", + aliases: ["mountain_railway"], + tags: [], + category: "Travel & Places", + description: "mountain railway", + unicode_version: "6.0", + }, + { + emoji: "🚋", + aliases: ["train"], + tags: [], + category: "Travel & Places", + description: "tram car", + unicode_version: "6.0", + }, + { + emoji: "🚌", + aliases: ["bus"], + tags: [], + category: "Travel & Places", + description: "bus", + unicode_version: "6.0", + }, + { + emoji: "🚍", + aliases: ["oncoming_bus"], + tags: [], + category: "Travel & Places", + description: "oncoming bus", + unicode_version: "6.0", + }, + { + emoji: "🚎", + aliases: ["trolleybus"], + tags: [], + category: "Travel & Places", + description: "trolleybus", + unicode_version: "6.0", + }, + { + emoji: "🚐", + aliases: ["minibus"], + tags: [], + category: "Travel & Places", + description: "minibus", + unicode_version: "6.0", + }, + { + emoji: "🚑", + aliases: ["ambulance"], + tags: [], + category: "Travel & Places", + description: "ambulance", + unicode_version: "6.0", + }, + { + emoji: "🚒", + aliases: ["fire_engine"], + tags: [], + category: "Travel & Places", + description: "fire engine", + unicode_version: "6.0", + }, + { + emoji: "🚓", + aliases: ["police_car"], + tags: [], + category: "Travel & Places", + description: "police car", + unicode_version: "6.0", + }, + { + emoji: "🚔", + aliases: ["oncoming_police_car"], + tags: [], + category: "Travel & Places", + description: "oncoming police car", + unicode_version: "6.0", + }, + { + emoji: "🚕", + aliases: ["taxi"], + tags: [], + category: "Travel & Places", + description: "taxi", + unicode_version: "6.0", + }, + { + emoji: "🚖", + aliases: ["oncoming_taxi"], + tags: [], + category: "Travel & Places", + description: "oncoming taxi", + unicode_version: "6.0", + }, + { + emoji: "🚗", + aliases: ["car", "red_car"], + tags: [], + category: "Travel & Places", + description: "automobile", + unicode_version: "6.0", + }, + { + emoji: "🚘", + aliases: ["oncoming_automobile"], + tags: [], + category: "Travel & Places", + description: "oncoming automobile", + unicode_version: "6.0", + }, + { + emoji: "🚙", + aliases: ["blue_car"], + tags: [], + category: "Travel & Places", + description: "sport utility vehicle", + unicode_version: "6.0", + }, + { + emoji: "🛻", + aliases: ["pickup_truck"], + tags: [], + category: "Travel & Places", + description: "pickup truck", + unicode_version: "13.0", + }, + { + emoji: "🚚", + aliases: ["truck"], + tags: [], + category: "Travel & Places", + description: "delivery truck", + unicode_version: "6.0", + }, + { + emoji: "🚛", + aliases: ["articulated_lorry"], + tags: [], + category: "Travel & Places", + description: "articulated lorry", + unicode_version: "6.0", + }, + { + emoji: "🚜", + aliases: ["tractor"], + tags: [], + category: "Travel & Places", + description: "tractor", + unicode_version: "6.0", + }, + { + emoji: "🏎️", + aliases: ["racing_car"], + tags: [], + category: "Travel & Places", + description: "racing car", + unicode_version: "7.0", + }, + { + emoji: "🏍️", + aliases: ["motorcycle"], + tags: [], + category: "Travel & Places", + description: "motorcycle", + unicode_version: "7.0", + }, + { + emoji: "🛵", + aliases: ["motor_scooter"], + tags: [], + category: "Travel & Places", + description: "motor scooter", + unicode_version: "9.0", + }, + { + emoji: "🦽", + aliases: ["manual_wheelchair"], + tags: [], + category: "Travel & Places", + description: "manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🦼", + aliases: ["motorized_wheelchair"], + tags: [], + category: "Travel & Places", + description: "motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🛺", + aliases: ["auto_rickshaw"], + tags: [], + category: "Travel & Places", + description: "auto rickshaw", + unicode_version: "12.0", + }, + { + emoji: "🚲", + aliases: ["bike"], + tags: ["bicycle"], + category: "Travel & Places", + description: "bicycle", + unicode_version: "6.0", + }, + { + emoji: "🛴", + aliases: ["kick_scooter"], + tags: [], + category: "Travel & Places", + description: "kick scooter", + unicode_version: "9.0", + }, + { + emoji: "🛹", + aliases: ["skateboard"], + tags: [], + category: "Travel & Places", + description: "skateboard", + unicode_version: "11.0", + }, + { + emoji: "🛼", + aliases: ["roller_skate"], + tags: [], + category: "Travel & Places", + description: "roller skate", + unicode_version: "13.0", + }, + { + emoji: "🚏", + aliases: ["busstop"], + tags: [], + category: "Travel & Places", + description: "bus stop", + unicode_version: "6.0", + }, + { + emoji: "🛣️", + aliases: ["motorway"], + tags: [], + category: "Travel & Places", + description: "motorway", + unicode_version: "7.0", + }, + { + emoji: "🛤️", + aliases: ["railway_track"], + tags: [], + category: "Travel & Places", + description: "railway track", + unicode_version: "7.0", + }, + { + emoji: "🛢️", + aliases: ["oil_drum"], + tags: [], + category: "Travel & Places", + description: "oil drum", + unicode_version: "7.0", + }, + { + emoji: "⛽", + aliases: ["fuelpump"], + tags: [], + category: "Travel & Places", + description: "fuel pump", + unicode_version: "5.2", + }, + { + emoji: "🚨", + aliases: ["rotating_light"], + tags: ["911", "emergency"], + category: "Travel & Places", + description: "police car light", + unicode_version: "6.0", + }, + { + emoji: "🚥", + aliases: ["traffic_light"], + tags: [], + category: "Travel & Places", + description: "horizontal traffic light", + unicode_version: "6.0", + }, + { + emoji: "🚦", + aliases: ["vertical_traffic_light"], + tags: ["semaphore"], + category: "Travel & Places", + description: "vertical traffic light", + unicode_version: "6.0", + }, + { + emoji: "🛑", + aliases: ["stop_sign"], + tags: [], + category: "Travel & Places", + description: "stop sign", + unicode_version: "9.0", + }, + { + emoji: "🚧", + aliases: ["construction"], + tags: ["wip"], + category: "Travel & Places", + description: "construction", + unicode_version: "6.0", + }, + { + emoji: "⚓", + aliases: ["anchor"], + tags: ["ship"], + category: "Travel & Places", + description: "anchor", + unicode_version: "4.1", + }, + { + emoji: "⛵", + aliases: ["boat", "sailboat"], + tags: [], + category: "Travel & Places", + description: "sailboat", + unicode_version: "5.2", + }, + { + emoji: "🛶", + aliases: ["canoe"], + tags: [], + category: "Travel & Places", + description: "canoe", + unicode_version: "9.0", + }, + { + emoji: "🚤", + aliases: ["speedboat"], + tags: ["ship"], + category: "Travel & Places", + description: "speedboat", + unicode_version: "6.0", + }, + { + emoji: "🛳️", + aliases: ["passenger_ship"], + tags: ["cruise"], + category: "Travel & Places", + description: "passenger ship", + unicode_version: "7.0", + }, + { + emoji: "⛴️", + aliases: ["ferry"], + tags: [], + category: "Travel & Places", + description: "ferry", + unicode_version: "5.2", + }, + { + emoji: "🛥️", + aliases: ["motor_boat"], + tags: [], + category: "Travel & Places", + description: "motor boat", + unicode_version: "7.0", + }, + { + emoji: "🚢", + aliases: ["ship"], + tags: [], + category: "Travel & Places", + description: "ship", + unicode_version: "6.0", + }, + { + emoji: "✈️", + aliases: ["airplane"], + tags: ["flight"], + category: "Travel & Places", + description: "airplane", + unicode_version: "", + }, + { + emoji: "🛩️", + aliases: ["small_airplane"], + tags: ["flight"], + category: "Travel & Places", + description: "small airplane", + unicode_version: "7.0", + }, + { + emoji: "🛫", + aliases: ["flight_departure"], + tags: [], + category: "Travel & Places", + description: "airplane departure", + unicode_version: "7.0", + }, + { + emoji: "🛬", + aliases: ["flight_arrival"], + tags: [], + category: "Travel & Places", + description: "airplane arrival", + unicode_version: "7.0", + }, + { + emoji: "🪂", + aliases: ["parachute"], + tags: [], + category: "Travel & Places", + description: "parachute", + unicode_version: "12.0", + }, + { + emoji: "💺", + aliases: ["seat"], + tags: [], + category: "Travel & Places", + description: "seat", + unicode_version: "6.0", + }, + { + emoji: "🚁", + aliases: ["helicopter"], + tags: [], + category: "Travel & Places", + description: "helicopter", + unicode_version: "6.0", + }, + { + emoji: "🚟", + aliases: ["suspension_railway"], + tags: [], + category: "Travel & Places", + description: "suspension railway", + unicode_version: "6.0", + }, + { + emoji: "🚠", + aliases: ["mountain_cableway"], + tags: [], + category: "Travel & Places", + description: "mountain cableway", + unicode_version: "6.0", + }, + { + emoji: "🚡", + aliases: ["aerial_tramway"], + tags: [], + category: "Travel & Places", + description: "aerial tramway", + unicode_version: "6.0", + }, + { + emoji: "🛰️", + aliases: ["artificial_satellite"], + tags: ["orbit", "space"], + category: "Travel & Places", + description: "satellite", + unicode_version: "7.0", + }, + { + emoji: "🚀", + aliases: ["rocket"], + tags: ["ship", "launch"], + category: "Travel & Places", + description: "rocket", + unicode_version: "6.0", + }, + { + emoji: "🛸", + aliases: ["flying_saucer"], + tags: ["ufo"], + category: "Travel & Places", + description: "flying saucer", + unicode_version: "11.0", + }, + { + emoji: "🛎️", + aliases: ["bellhop_bell"], + tags: [], + category: "Travel & Places", + description: "bellhop bell", + unicode_version: "7.0", + }, + { + emoji: "🧳", + aliases: ["luggage"], + tags: [], + category: "Travel & Places", + description: "luggage", + unicode_version: "11.0", + }, + { + emoji: "⌛", + aliases: ["hourglass"], + tags: ["time"], + category: "Travel & Places", + description: "hourglass done", + unicode_version: "", + }, + { + emoji: "⏳", + aliases: ["hourglass_flowing_sand"], + tags: ["time"], + category: "Travel & Places", + description: "hourglass not done", + unicode_version: "6.0", + }, + { + emoji: "⌚", + aliases: ["watch"], + tags: ["time"], + category: "Travel & Places", + description: "watch", + unicode_version: "", + }, + { + emoji: "⏰", + aliases: ["alarm_clock"], + tags: ["morning"], + category: "Travel & Places", + description: "alarm clock", + unicode_version: "6.0", + }, + { + emoji: "⏱️", + aliases: ["stopwatch"], + tags: [], + category: "Travel & Places", + description: "stopwatch", + unicode_version: "6.0", + }, + { + emoji: "⏲️", + aliases: ["timer_clock"], + tags: [], + category: "Travel & Places", + description: "timer clock", + unicode_version: "6.0", + }, + { + emoji: "🕰️", + aliases: ["mantelpiece_clock"], + tags: [], + category: "Travel & Places", + description: "mantelpiece clock", + unicode_version: "7.0", + }, + { + emoji: "🕛", + aliases: ["clock12"], + tags: [], + category: "Travel & Places", + description: "twelve o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕧", + aliases: ["clock1230"], + tags: [], + category: "Travel & Places", + description: "twelve-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕐", + aliases: ["clock1"], + tags: [], + category: "Travel & Places", + description: "one o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕜", + aliases: ["clock130"], + tags: [], + category: "Travel & Places", + description: "one-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕑", + aliases: ["clock2"], + tags: [], + category: "Travel & Places", + description: "two o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕝", + aliases: ["clock230"], + tags: [], + category: "Travel & Places", + description: "two-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕒", + aliases: ["clock3"], + tags: [], + category: "Travel & Places", + description: "three o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕞", + aliases: ["clock330"], + tags: [], + category: "Travel & Places", + description: "three-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕓", + aliases: ["clock4"], + tags: [], + category: "Travel & Places", + description: "four o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕟", + aliases: ["clock430"], + tags: [], + category: "Travel & Places", + description: "four-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕔", + aliases: ["clock5"], + tags: [], + category: "Travel & Places", + description: "five o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕠", + aliases: ["clock530"], + tags: [], + category: "Travel & Places", + description: "five-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕕", + aliases: ["clock6"], + tags: [], + category: "Travel & Places", + description: "six o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕡", + aliases: ["clock630"], + tags: [], + category: "Travel & Places", + description: "six-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕖", + aliases: ["clock7"], + tags: [], + category: "Travel & Places", + description: "seven o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕢", + aliases: ["clock730"], + tags: [], + category: "Travel & Places", + description: "seven-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕗", + aliases: ["clock8"], + tags: [], + category: "Travel & Places", + description: "eight o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕣", + aliases: ["clock830"], + tags: [], + category: "Travel & Places", + description: "eight-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕘", + aliases: ["clock9"], + tags: [], + category: "Travel & Places", + description: "nine o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕤", + aliases: ["clock930"], + tags: [], + category: "Travel & Places", + description: "nine-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕙", + aliases: ["clock10"], + tags: [], + category: "Travel & Places", + description: "ten o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕥", + aliases: ["clock1030"], + tags: [], + category: "Travel & Places", + description: "ten-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕚", + aliases: ["clock11"], + tags: [], + category: "Travel & Places", + description: "eleven o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕦", + aliases: ["clock1130"], + tags: [], + category: "Travel & Places", + description: "eleven-thirty", + unicode_version: "6.0", + }, + { + emoji: "🌑", + aliases: ["new_moon"], + tags: [], + category: "Travel & Places", + description: "new moon", + unicode_version: "6.0", + }, + { + emoji: "🌒", + aliases: ["waxing_crescent_moon"], + tags: [], + category: "Travel & Places", + description: "waxing crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌓", + aliases: ["first_quarter_moon"], + tags: [], + category: "Travel & Places", + description: "first quarter moon", + unicode_version: "6.0", + }, + { + emoji: "🌔", + aliases: ["moon", "waxing_gibbous_moon"], + tags: [], + category: "Travel & Places", + description: "waxing gibbous moon", + unicode_version: "6.0", + }, + { + emoji: "🌕", + aliases: ["full_moon"], + tags: [], + category: "Travel & Places", + description: "full moon", + unicode_version: "6.0", + }, + { + emoji: "🌖", + aliases: ["waning_gibbous_moon"], + tags: [], + category: "Travel & Places", + description: "waning gibbous moon", + unicode_version: "6.0", + }, + { + emoji: "🌗", + aliases: ["last_quarter_moon"], + tags: [], + category: "Travel & Places", + description: "last quarter moon", + unicode_version: "6.0", + }, + { + emoji: "🌘", + aliases: ["waning_crescent_moon"], + tags: [], + category: "Travel & Places", + description: "waning crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌙", + aliases: ["crescent_moon"], + tags: ["night"], + category: "Travel & Places", + description: "crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌚", + aliases: ["new_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "new moon face", + unicode_version: "6.0", + }, + { + emoji: "🌛", + aliases: ["first_quarter_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "first quarter moon face", + unicode_version: "6.0", + }, + { + emoji: "🌜", + aliases: ["last_quarter_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "last quarter moon face", + unicode_version: "6.0", + }, + { + emoji: "🌡️", + aliases: ["thermometer"], + tags: [], + category: "Travel & Places", + description: "thermometer", + unicode_version: "7.0", + }, + { + emoji: "☀️", + aliases: ["sunny"], + tags: ["weather"], + category: "Travel & Places", + description: "sun", + unicode_version: "", + }, + { + emoji: "🌝", + aliases: ["full_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "full moon face", + unicode_version: "6.0", + }, + { + emoji: "🌞", + aliases: ["sun_with_face"], + tags: ["summer"], + category: "Travel & Places", + description: "sun with face", + unicode_version: "6.0", + }, + { + emoji: "🪐", + aliases: ["ringed_planet"], + tags: [], + category: "Travel & Places", + description: "ringed planet", + unicode_version: "12.0", + }, + { + emoji: "⭐", + aliases: ["star"], + tags: [], + category: "Travel & Places", + description: "star", + unicode_version: "5.1", + }, + { + emoji: "🌟", + aliases: ["star2"], + tags: [], + category: "Travel & Places", + description: "glowing star", + unicode_version: "6.0", + }, + { + emoji: "🌠", + aliases: ["stars"], + tags: [], + category: "Travel & Places", + description: "shooting star", + unicode_version: "6.0", + }, + { + emoji: "🌌", + aliases: ["milky_way"], + tags: [], + category: "Travel & Places", + description: "milky way", + unicode_version: "6.0", + }, + { + emoji: "☁️", + aliases: ["cloud"], + tags: [], + category: "Travel & Places", + description: "cloud", + unicode_version: "", + }, + { + emoji: "⛅", + aliases: ["partly_sunny"], + tags: ["weather", "cloud"], + category: "Travel & Places", + description: "sun behind cloud", + unicode_version: "5.2", + }, + { + emoji: "⛈️", + aliases: ["cloud_with_lightning_and_rain"], + tags: [], + category: "Travel & Places", + description: "cloud with lightning and rain", + unicode_version: "5.2", + }, + { + emoji: "🌤️", + aliases: ["sun_behind_small_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind small cloud", + unicode_version: "7.0", + }, + { + emoji: "🌥️", + aliases: ["sun_behind_large_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind large cloud", + unicode_version: "7.0", + }, + { + emoji: "🌦️", + aliases: ["sun_behind_rain_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind rain cloud", + unicode_version: "7.0", + }, + { + emoji: "🌧️", + aliases: ["cloud_with_rain"], + tags: [], + category: "Travel & Places", + description: "cloud with rain", + unicode_version: "7.0", + }, + { + emoji: "🌨️", + aliases: ["cloud_with_snow"], + tags: [], + category: "Travel & Places", + description: "cloud with snow", + unicode_version: "7.0", + }, + { + emoji: "🌩️", + aliases: ["cloud_with_lightning"], + tags: [], + category: "Travel & Places", + description: "cloud with lightning", + unicode_version: "7.0", + }, + { + emoji: "🌪️", + aliases: ["tornado"], + tags: [], + category: "Travel & Places", + description: "tornado", + unicode_version: "7.0", + }, + { + emoji: "🌫️", + aliases: ["fog"], + tags: [], + category: "Travel & Places", + description: "fog", + unicode_version: "7.0", + }, + { + emoji: "🌬️", + aliases: ["wind_face"], + tags: [], + category: "Travel & Places", + description: "wind face", + unicode_version: "7.0", + }, + { + emoji: "🌀", + aliases: ["cyclone"], + tags: ["swirl"], + category: "Travel & Places", + description: "cyclone", + unicode_version: "6.0", + }, + { + emoji: "🌈", + aliases: ["rainbow"], + tags: [], + category: "Travel & Places", + description: "rainbow", + unicode_version: "6.0", + }, + { + emoji: "🌂", + aliases: ["closed_umbrella"], + tags: ["weather", "rain"], + category: "Travel & Places", + description: "closed umbrella", + unicode_version: "6.0", + }, + { + emoji: "☂️", + aliases: ["open_umbrella"], + tags: [], + category: "Travel & Places", + description: "umbrella", + unicode_version: "", + }, + { + emoji: "☔", + aliases: ["umbrella"], + tags: ["rain", "weather"], + category: "Travel & Places", + description: "umbrella with rain drops", + unicode_version: "4.0", + }, + { + emoji: "⛱️", + aliases: ["parasol_on_ground"], + tags: ["beach_umbrella"], + category: "Travel & Places", + description: "umbrella on ground", + unicode_version: "5.2", + }, + { + emoji: "⚡", + aliases: ["zap"], + tags: ["lightning", "thunder"], + category: "Travel & Places", + description: "high voltage", + unicode_version: "4.0", + }, + { + emoji: "❄️", + aliases: ["snowflake"], + tags: ["winter", "cold", "weather"], + category: "Travel & Places", + description: "snowflake", + unicode_version: "", + }, + { + emoji: "☃️", + aliases: ["snowman_with_snow"], + tags: ["winter", "christmas"], + category: "Travel & Places", + description: "snowman", + unicode_version: "", + }, + { + emoji: "⛄", + aliases: ["snowman"], + tags: ["winter"], + category: "Travel & Places", + description: "snowman without snow", + unicode_version: "5.2", + }, + { + emoji: "☄️", + aliases: ["comet"], + tags: [], + category: "Travel & Places", + description: "comet", + unicode_version: "", + }, + { + emoji: "🔥", + aliases: ["fire"], + tags: ["burn"], + category: "Travel & Places", + description: "fire", + unicode_version: "6.0", + }, + { + emoji: "💧", + aliases: ["droplet"], + tags: ["water"], + category: "Travel & Places", + description: "droplet", + unicode_version: "6.0", + }, + { + emoji: "🌊", + aliases: ["ocean"], + tags: ["sea"], + category: "Travel & Places", + description: "water wave", + unicode_version: "6.0", + }, + { + emoji: "🎃", + aliases: ["jack_o_lantern"], + tags: ["halloween"], + category: "Activities", + description: "jack-o-lantern", + unicode_version: "6.0", + }, + { + emoji: "🎄", + aliases: ["christmas_tree"], + tags: [], + category: "Activities", + description: "Christmas tree", + unicode_version: "6.0", + }, + { + emoji: "🎆", + aliases: ["fireworks"], + tags: ["festival", "celebration"], + category: "Activities", + description: "fireworks", + unicode_version: "6.0", + }, + { + emoji: "🎇", + aliases: ["sparkler"], + tags: [], + category: "Activities", + description: "sparkler", + unicode_version: "6.0", + }, + { + emoji: "🧨", + aliases: ["firecracker"], + tags: [], + category: "Activities", + description: "firecracker", + unicode_version: "11.0", + }, + { + emoji: "✨", + aliases: ["sparkles"], + tags: ["shiny"], + category: "Activities", + description: "sparkles", + unicode_version: "6.0", + }, + { + emoji: "🎈", + aliases: ["balloon"], + tags: ["party", "birthday"], + category: "Activities", + description: "balloon", + unicode_version: "6.0", + }, + { + emoji: "🎉", + aliases: ["tada"], + tags: ["hooray", "party"], + category: "Activities", + description: "party popper", + unicode_version: "6.0", + }, + { + emoji: "🎊", + aliases: ["confetti_ball"], + tags: [], + category: "Activities", + description: "confetti ball", + unicode_version: "6.0", + }, + { + emoji: "🎋", + aliases: ["tanabata_tree"], + tags: [], + category: "Activities", + description: "tanabata tree", + unicode_version: "6.0", + }, + { + emoji: "🎍", + aliases: ["bamboo"], + tags: [], + category: "Activities", + description: "pine decoration", + unicode_version: "6.0", + }, + { + emoji: "🎎", + aliases: ["dolls"], + tags: [], + category: "Activities", + description: "Japanese dolls", + unicode_version: "6.0", + }, + { + emoji: "🎏", + aliases: ["flags"], + tags: [], + category: "Activities", + description: "carp streamer", + unicode_version: "6.0", + }, + { + emoji: "🎐", + aliases: ["wind_chime"], + tags: [], + category: "Activities", + description: "wind chime", + unicode_version: "6.0", + }, + { + emoji: "🎑", + aliases: ["rice_scene"], + tags: [], + category: "Activities", + description: "moon viewing ceremony", + unicode_version: "6.0", + }, + { + emoji: "🧧", + aliases: ["red_envelope"], + tags: [], + category: "Activities", + description: "red envelope", + unicode_version: "11.0", + }, + { + emoji: "🎀", + aliases: ["ribbon"], + tags: [], + category: "Activities", + description: "ribbon", + unicode_version: "6.0", + }, + { + emoji: "🎁", + aliases: ["gift"], + tags: ["present", "birthday", "christmas"], + category: "Activities", + description: "wrapped gift", + unicode_version: "6.0", + }, + { + emoji: "🎗️", + aliases: ["reminder_ribbon"], + tags: [], + category: "Activities", + description: "reminder ribbon", + unicode_version: "7.0", + }, + { + emoji: "🎟️", + aliases: ["tickets"], + tags: [], + category: "Activities", + description: "admission tickets", + unicode_version: "7.0", + }, + { + emoji: "🎫", + aliases: ["ticket"], + tags: [], + category: "Activities", + description: "ticket", + unicode_version: "6.0", + }, + { + emoji: "🎖️", + aliases: ["medal_military"], + tags: [], + category: "Activities", + description: "military medal", + unicode_version: "7.0", + }, + { + emoji: "🏆", + aliases: ["trophy"], + tags: ["award", "contest", "winner"], + category: "Activities", + description: "trophy", + unicode_version: "6.0", + }, + { + emoji: "🏅", + aliases: ["medal_sports"], + tags: ["gold", "winner"], + category: "Activities", + description: "sports medal", + unicode_version: "7.0", + }, + { + emoji: "🥇", + aliases: ["1st_place_medal"], + tags: ["gold"], + category: "Activities", + description: "1st place medal", + unicode_version: "9.0", + }, + { + emoji: "🥈", + aliases: ["2nd_place_medal"], + tags: ["silver"], + category: "Activities", + description: "2nd place medal", + unicode_version: "9.0", + }, + { + emoji: "🥉", + aliases: ["3rd_place_medal"], + tags: ["bronze"], + category: "Activities", + description: "3rd place medal", + unicode_version: "9.0", + }, + { + emoji: "⚽", + aliases: ["soccer"], + tags: ["sports"], + category: "Activities", + description: "soccer ball", + unicode_version: "5.2", + }, + { + emoji: "⚾", + aliases: ["baseball"], + tags: ["sports"], + category: "Activities", + description: "baseball", + unicode_version: "5.2", + }, + { + emoji: "🥎", + aliases: ["softball"], + tags: [], + category: "Activities", + description: "softball", + unicode_version: "11.0", + }, + { + emoji: "🏀", + aliases: ["basketball"], + tags: ["sports"], + category: "Activities", + description: "basketball", + unicode_version: "6.0", + }, + { + emoji: "🏐", + aliases: ["volleyball"], + tags: [], + category: "Activities", + description: "volleyball", + unicode_version: "8.0", + }, + { + emoji: "🏈", + aliases: ["football"], + tags: ["sports"], + category: "Activities", + description: "american football", + unicode_version: "6.0", + }, + { + emoji: "🏉", + aliases: ["rugby_football"], + tags: [], + category: "Activities", + description: "rugby football", + unicode_version: "6.0", + }, + { + emoji: "🎾", + aliases: ["tennis"], + tags: ["sports"], + category: "Activities", + description: "tennis", + unicode_version: "6.0", + }, + { + emoji: "🥏", + aliases: ["flying_disc"], + tags: [], + category: "Activities", + description: "flying disc", + unicode_version: "11.0", + }, + { + emoji: "🎳", + aliases: ["bowling"], + tags: [], + category: "Activities", + description: "bowling", + unicode_version: "6.0", + }, + { + emoji: "🏏", + aliases: ["cricket_game"], + tags: [], + category: "Activities", + description: "cricket game", + unicode_version: "8.0", + }, + { + emoji: "🏑", + aliases: ["field_hockey"], + tags: [], + category: "Activities", + description: "field hockey", + unicode_version: "8.0", + }, + { + emoji: "🏒", + aliases: ["ice_hockey"], + tags: [], + category: "Activities", + description: "ice hockey", + unicode_version: "8.0", + }, + { + emoji: "🥍", + aliases: ["lacrosse"], + tags: [], + category: "Activities", + description: "lacrosse", + unicode_version: "11.0", + }, + { + emoji: "🏓", + aliases: ["ping_pong"], + tags: [], + category: "Activities", + description: "ping pong", + unicode_version: "8.0", + }, + { + emoji: "🏸", + aliases: ["badminton"], + tags: [], + category: "Activities", + description: "badminton", + unicode_version: "8.0", + }, + { + emoji: "🥊", + aliases: ["boxing_glove"], + tags: [], + category: "Activities", + description: "boxing glove", + unicode_version: "9.0", + }, + { + emoji: "🥋", + aliases: ["martial_arts_uniform"], + tags: [], + category: "Activities", + description: "martial arts uniform", + unicode_version: "9.0", + }, + { + emoji: "🥅", + aliases: ["goal_net"], + tags: [], + category: "Activities", + description: "goal net", + unicode_version: "9.0", + }, + { + emoji: "⛳", + aliases: ["golf"], + tags: [], + category: "Activities", + description: "flag in hole", + unicode_version: "5.2", + }, + { + emoji: "⛸️", + aliases: ["ice_skate"], + tags: ["skating"], + category: "Activities", + description: "ice skate", + unicode_version: "5.2", + }, + { + emoji: "🎣", + aliases: ["fishing_pole_and_fish"], + tags: [], + category: "Activities", + description: "fishing pole", + unicode_version: "6.0", + }, + { + emoji: "🤿", + aliases: ["diving_mask"], + tags: [], + category: "Activities", + description: "diving mask", + unicode_version: "12.0", + }, + { + emoji: "🎽", + aliases: ["running_shirt_with_sash"], + tags: ["marathon"], + category: "Activities", + description: "running shirt", + unicode_version: "6.0", + }, + { + emoji: "🎿", + aliases: ["ski"], + tags: [], + category: "Activities", + description: "skis", + unicode_version: "6.0", + }, + { + emoji: "🛷", + aliases: ["sled"], + tags: [], + category: "Activities", + description: "sled", + unicode_version: "11.0", + }, + { + emoji: "🥌", + aliases: ["curling_stone"], + tags: [], + category: "Activities", + description: "curling stone", + unicode_version: "11.0", + }, + { + emoji: "🎯", + aliases: ["dart"], + tags: ["target"], + category: "Activities", + description: "bullseye", + unicode_version: "6.0", + }, + { + emoji: "🪀", + aliases: ["yo_yo"], + tags: [], + category: "Activities", + description: "yo-yo", + unicode_version: "12.0", + }, + { + emoji: "🪁", + aliases: ["kite"], + tags: [], + category: "Activities", + description: "kite", + unicode_version: "12.0", + }, + { + emoji: "🎱", + aliases: ["8ball"], + tags: ["pool", "billiards"], + category: "Activities", + description: "pool 8 ball", + unicode_version: "6.0", + }, + { + emoji: "🔮", + aliases: ["crystal_ball"], + tags: ["fortune"], + category: "Activities", + description: "crystal ball", + unicode_version: "6.0", + }, + { + emoji: "🪄", + aliases: ["magic_wand"], + tags: [], + category: "Activities", + description: "magic wand", + unicode_version: "13.0", + }, + { + emoji: "🧿", + aliases: ["nazar_amulet"], + tags: [], + category: "Activities", + description: "nazar amulet", + unicode_version: "11.0", + }, + { + emoji: "🎮", + aliases: ["video_game"], + tags: ["play", "controller", "console"], + category: "Activities", + description: "video game", + unicode_version: "6.0", + }, + { + emoji: "🕹️", + aliases: ["joystick"], + tags: [], + category: "Activities", + description: "joystick", + unicode_version: "7.0", + }, + { + emoji: "🎰", + aliases: ["slot_machine"], + tags: [], + category: "Activities", + description: "slot machine", + unicode_version: "6.0", + }, + { + emoji: "🎲", + aliases: ["game_die"], + tags: ["dice", "gambling"], + category: "Activities", + description: "game die", + unicode_version: "6.0", + }, + { + emoji: "🧩", + aliases: ["jigsaw"], + tags: [], + category: "Activities", + description: "puzzle piece", + unicode_version: "11.0", + }, + { + emoji: "🧸", + aliases: ["teddy_bear"], + tags: [], + category: "Activities", + description: "teddy bear", + unicode_version: "11.0", + }, + { + emoji: "🪅", + aliases: ["pinata"], + tags: [], + category: "Activities", + description: "piñata", + unicode_version: "13.0", + }, + { + emoji: "🪆", + aliases: ["nesting_dolls"], + tags: [], + category: "Activities", + description: "nesting dolls", + unicode_version: "13.0", + }, + { + emoji: "♠️", + aliases: ["spades"], + tags: [], + category: "Activities", + description: "spade suit", + unicode_version: "", + }, + { + emoji: "♥️", + aliases: ["hearts"], + tags: [], + category: "Activities", + description: "heart suit", + unicode_version: "", + }, + { + emoji: "♦️", + aliases: ["diamonds"], + tags: [], + category: "Activities", + description: "diamond suit", + unicode_version: "", + }, + { + emoji: "♣️", + aliases: ["clubs"], + tags: [], + category: "Activities", + description: "club suit", + unicode_version: "", + }, + { + emoji: "♟️", + aliases: ["chess_pawn"], + tags: [], + category: "Activities", + description: "chess pawn", + unicode_version: "11.0", + }, + { + emoji: "🃏", + aliases: ["black_joker"], + tags: [], + category: "Activities", + description: "joker", + unicode_version: "6.0", + }, + { + emoji: "🀄", + aliases: ["mahjong"], + tags: [], + category: "Activities", + description: "mahjong red dragon", + unicode_version: "", + }, + { + emoji: "🎴", + aliases: ["flower_playing_cards"], + tags: [], + category: "Activities", + description: "flower playing cards", + unicode_version: "6.0", + }, + { + emoji: "🎭", + aliases: ["performing_arts"], + tags: ["theater", "drama"], + category: "Activities", + description: "performing arts", + unicode_version: "6.0", + }, + { + emoji: "🖼️", + aliases: ["framed_picture"], + tags: [], + category: "Activities", + description: "framed picture", + unicode_version: "7.0", + }, + { + emoji: "🎨", + aliases: ["art"], + tags: ["design", "paint"], + category: "Activities", + description: "artist palette", + unicode_version: "6.0", + }, + { + emoji: "🧵", + aliases: ["thread"], + tags: [], + category: "Activities", + description: "thread", + unicode_version: "11.0", + }, + { + emoji: "🪡", + aliases: ["sewing_needle"], + tags: [], + category: "Activities", + description: "sewing needle", + unicode_version: "13.0", + }, + { + emoji: "🧶", + aliases: ["yarn"], + tags: [], + category: "Activities", + description: "yarn", + unicode_version: "11.0", + }, + { + emoji: "🪢", + aliases: ["knot"], + tags: [], + category: "Activities", + description: "knot", + unicode_version: "13.0", + }, + { + emoji: "👓", + aliases: ["eyeglasses"], + tags: ["glasses"], + category: "Objects", + description: "glasses", + unicode_version: "6.0", + }, + { + emoji: "🕶️", + aliases: ["dark_sunglasses"], + tags: [], + category: "Objects", + description: "sunglasses", + unicode_version: "7.0", + }, + { + emoji: "🥽", + aliases: ["goggles"], + tags: [], + category: "Objects", + description: "goggles", + unicode_version: "11.0", + }, + { + emoji: "🥼", + aliases: ["lab_coat"], + tags: [], + category: "Objects", + description: "lab coat", + unicode_version: "11.0", + }, + { + emoji: "🦺", + aliases: ["safety_vest"], + tags: [], + category: "Objects", + description: "safety vest", + unicode_version: "12.0", + }, + { + emoji: "👔", + aliases: ["necktie"], + tags: ["shirt", "formal"], + category: "Objects", + description: "necktie", + unicode_version: "6.0", + }, + { + emoji: "👕", + aliases: ["shirt", "tshirt"], + tags: [], + category: "Objects", + description: "t-shirt", + unicode_version: "6.0", + }, + { + emoji: "👖", + aliases: ["jeans"], + tags: ["pants"], + category: "Objects", + description: "jeans", + unicode_version: "6.0", + }, + { + emoji: "🧣", + aliases: ["scarf"], + tags: [], + category: "Objects", + description: "scarf", + unicode_version: "11.0", + }, + { + emoji: "🧤", + aliases: ["gloves"], + tags: [], + category: "Objects", + description: "gloves", + unicode_version: "11.0", + }, + { + emoji: "🧥", + aliases: ["coat"], + tags: [], + category: "Objects", + description: "coat", + unicode_version: "11.0", + }, + { + emoji: "🧦", + aliases: ["socks"], + tags: [], + category: "Objects", + description: "socks", + unicode_version: "11.0", + }, + { + emoji: "👗", + aliases: ["dress"], + tags: [], + category: "Objects", + description: "dress", + unicode_version: "6.0", + }, + { + emoji: "👘", + aliases: ["kimono"], + tags: [], + category: "Objects", + description: "kimono", + unicode_version: "6.0", + }, + { + emoji: "🥻", + aliases: ["sari"], + tags: [], + category: "Objects", + description: "sari", + unicode_version: "12.0", + }, + { + emoji: "🩱", + aliases: ["one_piece_swimsuit"], + tags: [], + category: "Objects", + description: "one-piece swimsuit", + unicode_version: "12.0", + }, + { + emoji: "🩲", + aliases: ["swim_brief"], + tags: [], + category: "Objects", + description: "briefs", + unicode_version: "12.0", + }, + { + emoji: "🩳", + aliases: ["shorts"], + tags: [], + category: "Objects", + description: "shorts", + unicode_version: "12.0", + }, + { + emoji: "👙", + aliases: ["bikini"], + tags: ["beach"], + category: "Objects", + description: "bikini", + unicode_version: "6.0", + }, + { + emoji: "👚", + aliases: ["womans_clothes"], + tags: [], + category: "Objects", + description: "woman’s clothes", + unicode_version: "6.0", + }, + { + emoji: "👛", + aliases: ["purse"], + tags: [], + category: "Objects", + description: "purse", + unicode_version: "6.0", + }, + { + emoji: "👜", + aliases: ["handbag"], + tags: ["bag"], + category: "Objects", + description: "handbag", + unicode_version: "6.0", + }, + { + emoji: "👝", + aliases: ["pouch"], + tags: ["bag"], + category: "Objects", + description: "clutch bag", + unicode_version: "6.0", + }, + { + emoji: "🛍️", + aliases: ["shopping"], + tags: ["bags"], + category: "Objects", + description: "shopping bags", + unicode_version: "7.0", + }, + { + emoji: "🎒", + aliases: ["school_satchel"], + tags: [], + category: "Objects", + description: "backpack", + unicode_version: "6.0", + }, + { + emoji: "🩴", + aliases: ["thong_sandal"], + tags: [], + category: "Objects", + description: "thong sandal", + unicode_version: "13.0", + }, + { + emoji: "👞", + aliases: ["mans_shoe", "shoe"], + tags: [], + category: "Objects", + description: "man’s shoe", + unicode_version: "6.0", + }, + { + emoji: "👟", + aliases: ["athletic_shoe"], + tags: ["sneaker", "sport", "running"], + category: "Objects", + description: "running shoe", + unicode_version: "6.0", + }, + { + emoji: "🥾", + aliases: ["hiking_boot"], + tags: [], + category: "Objects", + description: "hiking boot", + unicode_version: "11.0", + }, + { + emoji: "🥿", + aliases: ["flat_shoe"], + tags: [], + category: "Objects", + description: "flat shoe", + unicode_version: "11.0", + }, + { + emoji: "👠", + aliases: ["high_heel"], + tags: ["shoe"], + category: "Objects", + description: "high-heeled shoe", + unicode_version: "6.0", + }, + { + emoji: "👡", + aliases: ["sandal"], + tags: ["shoe"], + category: "Objects", + description: "woman’s sandal", + unicode_version: "6.0", + }, + { + emoji: "🩰", + aliases: ["ballet_shoes"], + tags: [], + category: "Objects", + description: "ballet shoes", + unicode_version: "12.0", + }, + { + emoji: "👢", + aliases: ["boot"], + tags: [], + category: "Objects", + description: "woman’s boot", + unicode_version: "6.0", + }, + { + emoji: "👑", + aliases: ["crown"], + tags: ["king", "queen", "royal"], + category: "Objects", + description: "crown", + unicode_version: "6.0", + }, + { + emoji: "👒", + aliases: ["womans_hat"], + tags: [], + category: "Objects", + description: "woman’s hat", + unicode_version: "6.0", + }, + { + emoji: "🎩", + aliases: ["tophat"], + tags: ["hat", "classy"], + category: "Objects", + description: "top hat", + unicode_version: "6.0", + }, + { + emoji: "🎓", + aliases: ["mortar_board"], + tags: ["education", "college", "university", "graduation"], + category: "Objects", + description: "graduation cap", + unicode_version: "6.0", + }, + { + emoji: "🧢", + aliases: ["billed_cap"], + tags: [], + category: "Objects", + description: "billed cap", + unicode_version: "11.0", + }, + { + emoji: "🪖", + aliases: ["military_helmet"], + tags: [], + category: "Objects", + description: "military helmet", + unicode_version: "13.0", + }, + { + emoji: "⛑️", + aliases: ["rescue_worker_helmet"], + tags: [], + category: "Objects", + description: "rescue worker’s helmet", + unicode_version: "5.2", + }, + { + emoji: "📿", + aliases: ["prayer_beads"], + tags: [], + category: "Objects", + description: "prayer beads", + unicode_version: "8.0", + }, + { + emoji: "💄", + aliases: ["lipstick"], + tags: ["makeup"], + category: "Objects", + description: "lipstick", + unicode_version: "6.0", + }, + { + emoji: "💍", + aliases: ["ring"], + tags: ["wedding", "marriage", "engaged"], + category: "Objects", + description: "ring", + unicode_version: "6.0", + }, + { + emoji: "💎", + aliases: ["gem"], + tags: ["diamond"], + category: "Objects", + description: "gem stone", + unicode_version: "6.0", + }, + { + emoji: "🔇", + aliases: ["mute"], + tags: ["sound", "volume"], + category: "Objects", + description: "muted speaker", + unicode_version: "6.0", + }, + { + emoji: "🔈", + aliases: ["speaker"], + tags: [], + category: "Objects", + description: "speaker low volume", + unicode_version: "6.0", + }, + { + emoji: "🔉", + aliases: ["sound"], + tags: ["volume"], + category: "Objects", + description: "speaker medium volume", + unicode_version: "6.0", + }, + { + emoji: "🔊", + aliases: ["loud_sound"], + tags: ["volume"], + category: "Objects", + description: "speaker high volume", + unicode_version: "6.0", + }, + { + emoji: "📢", + aliases: ["loudspeaker"], + tags: ["announcement"], + category: "Objects", + description: "loudspeaker", + unicode_version: "6.0", + }, + { + emoji: "📣", + aliases: ["mega"], + tags: [], + category: "Objects", + description: "megaphone", + unicode_version: "6.0", + }, + { + emoji: "📯", + aliases: ["postal_horn"], + tags: [], + category: "Objects", + description: "postal horn", + unicode_version: "6.0", + }, + { + emoji: "🔔", + aliases: ["bell"], + tags: ["sound", "notification"], + category: "Objects", + description: "bell", + unicode_version: "6.0", + }, + { + emoji: "🔕", + aliases: ["no_bell"], + tags: ["volume", "off"], + category: "Objects", + description: "bell with slash", + unicode_version: "6.0", + }, + { + emoji: "🎼", + aliases: ["musical_score"], + tags: [], + category: "Objects", + description: "musical score", + unicode_version: "6.0", + }, + { + emoji: "🎵", + aliases: ["musical_note"], + tags: [], + category: "Objects", + description: "musical note", + unicode_version: "6.0", + }, + { + emoji: "🎶", + aliases: ["notes"], + tags: ["music"], + category: "Objects", + description: "musical notes", + unicode_version: "6.0", + }, + { + emoji: "🎙️", + aliases: ["studio_microphone"], + tags: ["podcast"], + category: "Objects", + description: "studio microphone", + unicode_version: "7.0", + }, + { + emoji: "🎚️", + aliases: ["level_slider"], + tags: [], + category: "Objects", + description: "level slider", + unicode_version: "7.0", + }, + { + emoji: "🎛️", + aliases: ["control_knobs"], + tags: [], + category: "Objects", + description: "control knobs", + unicode_version: "7.0", + }, + { + emoji: "🎤", + aliases: ["microphone"], + tags: ["sing"], + category: "Objects", + description: "microphone", + unicode_version: "6.0", + }, + { + emoji: "🎧", + aliases: ["headphones"], + tags: ["music", "earphones"], + category: "Objects", + description: "headphone", + unicode_version: "6.0", + }, + { + emoji: "📻", + aliases: ["radio"], + tags: ["podcast"], + category: "Objects", + description: "radio", + unicode_version: "6.0", + }, + { + emoji: "🎷", + aliases: ["saxophone"], + tags: [], + category: "Objects", + description: "saxophone", + unicode_version: "6.0", + }, + { + emoji: "🪗", + aliases: ["accordion"], + tags: [], + category: "Objects", + description: "accordion", + unicode_version: "13.0", + }, + { + emoji: "🎸", + aliases: ["guitar"], + tags: ["rock"], + category: "Objects", + description: "guitar", + unicode_version: "6.0", + }, + { + emoji: "🎹", + aliases: ["musical_keyboard"], + tags: ["piano"], + category: "Objects", + description: "musical keyboard", + unicode_version: "6.0", + }, + { + emoji: "🎺", + aliases: ["trumpet"], + tags: [], + category: "Objects", + description: "trumpet", + unicode_version: "6.0", + }, + { + emoji: "🎻", + aliases: ["violin"], + tags: [], + category: "Objects", + description: "violin", + unicode_version: "6.0", + }, + { + emoji: "🪕", + aliases: ["banjo"], + tags: [], + category: "Objects", + description: "banjo", + unicode_version: "12.0", + }, + { + emoji: "🥁", + aliases: ["drum"], + tags: [], + category: "Objects", + description: "drum", + unicode_version: "", + }, + { + emoji: "🪘", + aliases: ["long_drum"], + tags: [], + category: "Objects", + description: "long drum", + unicode_version: "13.0", + }, + { + emoji: "📱", + aliases: ["iphone"], + tags: ["smartphone", "mobile"], + category: "Objects", + description: "mobile phone", + unicode_version: "6.0", + }, + { + emoji: "📲", + aliases: ["calling"], + tags: ["call", "incoming"], + category: "Objects", + description: "mobile phone with arrow", + unicode_version: "6.0", + }, + { + emoji: "☎️", + aliases: ["phone", "telephone"], + tags: [], + category: "Objects", + description: "telephone", + unicode_version: "", + }, + { + emoji: "📞", + aliases: ["telephone_receiver"], + tags: ["phone", "call"], + category: "Objects", + description: "telephone receiver", + unicode_version: "6.0", + }, + { + emoji: "📟", + aliases: ["pager"], + tags: [], + category: "Objects", + description: "pager", + unicode_version: "6.0", + }, + { + emoji: "📠", + aliases: ["fax"], + tags: [], + category: "Objects", + description: "fax machine", + unicode_version: "6.0", + }, + { + emoji: "🔋", + aliases: ["battery"], + tags: ["power"], + category: "Objects", + description: "battery", + unicode_version: "6.0", + }, + { + emoji: "🔌", + aliases: ["electric_plug"], + tags: [], + category: "Objects", + description: "electric plug", + unicode_version: "6.0", + }, + { + emoji: "💻", + aliases: ["computer"], + tags: ["desktop", "screen"], + category: "Objects", + description: "laptop", + unicode_version: "6.0", + }, + { + emoji: "🖥️", + aliases: ["desktop_computer"], + tags: [], + category: "Objects", + description: "desktop computer", + unicode_version: "7.0", + }, + { + emoji: "🖨️", + aliases: ["printer"], + tags: [], + category: "Objects", + description: "printer", + unicode_version: "7.0", + }, + { + emoji: "⌨️", + aliases: ["keyboard"], + tags: [], + category: "Objects", + description: "keyboard", + unicode_version: "", + }, + { + emoji: "🖱️", + aliases: ["computer_mouse"], + tags: [], + category: "Objects", + description: "computer mouse", + unicode_version: "7.0", + }, + { + emoji: "🖲️", + aliases: ["trackball"], + tags: [], + category: "Objects", + description: "trackball", + unicode_version: "7.0", + }, + { + emoji: "💽", + aliases: ["minidisc"], + tags: [], + category: "Objects", + description: "computer disk", + unicode_version: "6.0", + }, + { + emoji: "💾", + aliases: ["floppy_disk"], + tags: ["save"], + category: "Objects", + description: "floppy disk", + unicode_version: "6.0", + }, + { + emoji: "💿", + aliases: ["cd"], + tags: [], + category: "Objects", + description: "optical disk", + unicode_version: "6.0", + }, + { + emoji: "📀", + aliases: ["dvd"], + tags: [], + category: "Objects", + description: "dvd", + unicode_version: "6.0", + }, + { + emoji: "🧮", + aliases: ["abacus"], + tags: [], + category: "Objects", + description: "abacus", + unicode_version: "11.0", + }, + { + emoji: "🎥", + aliases: ["movie_camera"], + tags: ["film", "video"], + category: "Objects", + description: "movie camera", + unicode_version: "6.0", + }, + { + emoji: "🎞️", + aliases: ["film_strip"], + tags: [], + category: "Objects", + description: "film frames", + unicode_version: "7.0", + }, + { + emoji: "📽️", + aliases: ["film_projector"], + tags: [], + category: "Objects", + description: "film projector", + unicode_version: "7.0", + }, + { + emoji: "🎬", + aliases: ["clapper"], + tags: ["film"], + category: "Objects", + description: "clapper board", + unicode_version: "6.0", + }, + { + emoji: "📺", + aliases: ["tv"], + tags: [], + category: "Objects", + description: "television", + unicode_version: "6.0", + }, + { + emoji: "📷", + aliases: ["camera"], + tags: ["photo"], + category: "Objects", + description: "camera", + unicode_version: "6.0", + }, + { + emoji: "📸", + aliases: ["camera_flash"], + tags: ["photo"], + category: "Objects", + description: "camera with flash", + unicode_version: "7.0", + }, + { + emoji: "📹", + aliases: ["video_camera"], + tags: [], + category: "Objects", + description: "video camera", + unicode_version: "6.0", + }, + { + emoji: "📼", + aliases: ["vhs"], + tags: [], + category: "Objects", + description: "videocassette", + unicode_version: "6.0", + }, + { + emoji: "🔍", + aliases: ["mag"], + tags: ["search", "zoom"], + category: "Objects", + description: "magnifying glass tilted left", + unicode_version: "6.0", + }, + { + emoji: "🔎", + aliases: ["mag_right"], + tags: [], + category: "Objects", + description: "magnifying glass tilted right", + unicode_version: "6.0", + }, + { + emoji: "🕯️", + aliases: ["candle"], + tags: [], + category: "Objects", + description: "candle", + unicode_version: "7.0", + }, + { + emoji: "💡", + aliases: ["bulb"], + tags: ["idea", "light"], + category: "Objects", + description: "light bulb", + unicode_version: "6.0", + }, + { + emoji: "🔦", + aliases: ["flashlight"], + tags: [], + category: "Objects", + description: "flashlight", + unicode_version: "6.0", + }, + { + emoji: "🏮", + aliases: ["izakaya_lantern", "lantern"], + tags: [], + category: "Objects", + description: "red paper lantern", + unicode_version: "6.0", + }, + { + emoji: "🪔", + aliases: ["diya_lamp"], + tags: [], + category: "Objects", + description: "diya lamp", + unicode_version: "12.0", + }, + { + emoji: "📔", + aliases: ["notebook_with_decorative_cover"], + tags: [], + category: "Objects", + description: "notebook with decorative cover", + unicode_version: "6.0", + }, + { + emoji: "📕", + aliases: ["closed_book"], + tags: [], + category: "Objects", + description: "closed book", + unicode_version: "6.0", + }, + { + emoji: "📖", + aliases: ["book", "open_book"], + tags: [], + category: "Objects", + description: "open book", + unicode_version: "6.0", + }, + { + emoji: "📗", + aliases: ["green_book"], + tags: [], + category: "Objects", + description: "green book", + unicode_version: "6.0", + }, + { + emoji: "📘", + aliases: ["blue_book"], + tags: [], + category: "Objects", + description: "blue book", + unicode_version: "6.0", + }, + { + emoji: "📙", + aliases: ["orange_book"], + tags: [], + category: "Objects", + description: "orange book", + unicode_version: "6.0", + }, + { + emoji: "📚", + aliases: ["books"], + tags: ["library"], + category: "Objects", + description: "books", + unicode_version: "6.0", + }, + { + emoji: "📓", + aliases: ["notebook"], + tags: [], + category: "Objects", + description: "notebook", + unicode_version: "6.0", + }, + { + emoji: "📒", + aliases: ["ledger"], + tags: [], + category: "Objects", + description: "ledger", + unicode_version: "6.0", + }, + { + emoji: "📃", + aliases: ["page_with_curl"], + tags: [], + category: "Objects", + description: "page with curl", + unicode_version: "6.0", + }, + { + emoji: "📜", + aliases: ["scroll"], + tags: ["document"], + category: "Objects", + description: "scroll", + unicode_version: "6.0", + }, + { + emoji: "📄", + aliases: ["page_facing_up"], + tags: ["document"], + category: "Objects", + description: "page facing up", + unicode_version: "6.0", + }, + { + emoji: "📰", + aliases: ["newspaper"], + tags: ["press"], + category: "Objects", + description: "newspaper", + unicode_version: "6.0", + }, + { + emoji: "🗞️", + aliases: ["newspaper_roll"], + tags: ["press"], + category: "Objects", + description: "rolled-up newspaper", + unicode_version: "7.0", + }, + { + emoji: "📑", + aliases: ["bookmark_tabs"], + tags: [], + category: "Objects", + description: "bookmark tabs", + unicode_version: "6.0", + }, + { + emoji: "🔖", + aliases: ["bookmark"], + tags: [], + category: "Objects", + description: "bookmark", + unicode_version: "6.0", + }, + { + emoji: "🏷️", + aliases: ["label"], + tags: ["tag"], + category: "Objects", + description: "label", + unicode_version: "7.0", + }, + { + emoji: "💰", + aliases: ["moneybag"], + tags: ["dollar", "cream"], + category: "Objects", + description: "money bag", + unicode_version: "6.0", + }, + { + emoji: "🪙", + aliases: ["coin"], + tags: [], + category: "Objects", + description: "coin", + unicode_version: "13.0", + }, + { + emoji: "💴", + aliases: ["yen"], + tags: [], + category: "Objects", + description: "yen banknote", + unicode_version: "6.0", + }, + { + emoji: "💵", + aliases: ["dollar"], + tags: ["money"], + category: "Objects", + description: "dollar banknote", + unicode_version: "6.0", + }, + { + emoji: "💶", + aliases: ["euro"], + tags: [], + category: "Objects", + description: "euro banknote", + unicode_version: "6.0", + }, + { + emoji: "💷", + aliases: ["pound"], + tags: [], + category: "Objects", + description: "pound banknote", + unicode_version: "6.0", + }, + { + emoji: "💸", + aliases: ["money_with_wings"], + tags: ["dollar"], + category: "Objects", + description: "money with wings", + unicode_version: "6.0", + }, + { + emoji: "💳", + aliases: ["credit_card"], + tags: ["subscription"], + category: "Objects", + description: "credit card", + unicode_version: "6.0", + }, + { + emoji: "🧾", + aliases: ["receipt"], + tags: [], + category: "Objects", + description: "receipt", + unicode_version: "11.0", + }, + { + emoji: "💹", + aliases: ["chart"], + tags: [], + category: "Objects", + description: "chart increasing with yen", + unicode_version: "6.0", + }, + { + emoji: "✉️", + aliases: ["envelope"], + tags: ["letter", "email"], + category: "Objects", + description: "envelope", + unicode_version: "", + }, + { + emoji: "📧", + aliases: ["email", "e-mail"], + tags: [], + category: "Objects", + description: "e-mail", + unicode_version: "6.0", + }, + { + emoji: "📨", + aliases: ["incoming_envelope"], + tags: [], + category: "Objects", + description: "incoming envelope", + unicode_version: "6.0", + }, + { + emoji: "📩", + aliases: ["envelope_with_arrow"], + tags: [], + category: "Objects", + description: "envelope with arrow", + unicode_version: "6.0", + }, + { + emoji: "📤", + aliases: ["outbox_tray"], + tags: [], + category: "Objects", + description: "outbox tray", + unicode_version: "6.0", + }, + { + emoji: "📥", + aliases: ["inbox_tray"], + tags: [], + category: "Objects", + description: "inbox tray", + unicode_version: "6.0", + }, + { + emoji: "📦", + aliases: ["package"], + tags: ["shipping"], + category: "Objects", + description: "package", + unicode_version: "6.0", + }, + { + emoji: "📫", + aliases: ["mailbox"], + tags: [], + category: "Objects", + description: "closed mailbox with raised flag", + unicode_version: "6.0", + }, + { + emoji: "📪", + aliases: ["mailbox_closed"], + tags: [], + category: "Objects", + description: "closed mailbox with lowered flag", + unicode_version: "6.0", + }, + { + emoji: "📬", + aliases: ["mailbox_with_mail"], + tags: [], + category: "Objects", + description: "open mailbox with raised flag", + unicode_version: "6.0", + }, + { + emoji: "📭", + aliases: ["mailbox_with_no_mail"], + tags: [], + category: "Objects", + description: "open mailbox with lowered flag", + unicode_version: "6.0", + }, + { + emoji: "📮", + aliases: ["postbox"], + tags: [], + category: "Objects", + description: "postbox", + unicode_version: "6.0", + }, + { + emoji: "🗳️", + aliases: ["ballot_box"], + tags: [], + category: "Objects", + description: "ballot box with ballot", + unicode_version: "7.0", + }, + { + emoji: "✏️", + aliases: ["pencil2"], + tags: [], + category: "Objects", + description: "pencil", + unicode_version: "", + }, + { + emoji: "✒️", + aliases: ["black_nib"], + tags: [], + category: "Objects", + description: "black nib", + unicode_version: "", + }, + { + emoji: "🖋️", + aliases: ["fountain_pen"], + tags: [], + category: "Objects", + description: "fountain pen", + unicode_version: "7.0", + }, + { + emoji: "🖊️", + aliases: ["pen"], + tags: [], + category: "Objects", + description: "pen", + unicode_version: "7.0", + }, + { + emoji: "🖌️", + aliases: ["paintbrush"], + tags: [], + category: "Objects", + description: "paintbrush", + unicode_version: "7.0", + }, + { + emoji: "🖍️", + aliases: ["crayon"], + tags: [], + category: "Objects", + description: "crayon", + unicode_version: "7.0", + }, + { + emoji: "📝", + aliases: ["memo", "pencil"], + tags: ["document", "note"], + category: "Objects", + description: "memo", + unicode_version: "6.0", + }, + { + emoji: "💼", + aliases: ["briefcase"], + tags: ["business"], + category: "Objects", + description: "briefcase", + unicode_version: "6.0", + }, + { + emoji: "📁", + aliases: ["file_folder"], + tags: ["directory"], + category: "Objects", + description: "file folder", + unicode_version: "6.0", + }, + { + emoji: "📂", + aliases: ["open_file_folder"], + tags: [], + category: "Objects", + description: "open file folder", + unicode_version: "6.0", + }, + { + emoji: "🗂️", + aliases: ["card_index_dividers"], + tags: [], + category: "Objects", + description: "card index dividers", + unicode_version: "7.0", + }, + { + emoji: "📅", + aliases: ["date"], + tags: ["calendar", "schedule"], + category: "Objects", + description: "calendar", + unicode_version: "6.0", + }, + { + emoji: "📆", + aliases: ["calendar"], + tags: ["schedule"], + category: "Objects", + description: "tear-off calendar", + unicode_version: "6.0", + }, + { + emoji: "🗒️", + aliases: ["spiral_notepad"], + tags: [], + category: "Objects", + description: "spiral notepad", + unicode_version: "7.0", + }, + { + emoji: "🗓️", + aliases: ["spiral_calendar"], + tags: [], + category: "Objects", + description: "spiral calendar", + unicode_version: "7.0", + }, + { + emoji: "📇", + aliases: ["card_index"], + tags: [], + category: "Objects", + description: "card index", + unicode_version: "6.0", + }, + { + emoji: "📈", + aliases: ["chart_with_upwards_trend"], + tags: ["graph", "metrics"], + category: "Objects", + description: "chart increasing", + unicode_version: "6.0", + }, + { + emoji: "📉", + aliases: ["chart_with_downwards_trend"], + tags: ["graph", "metrics"], + category: "Objects", + description: "chart decreasing", + unicode_version: "6.0", + }, + { + emoji: "📊", + aliases: ["bar_chart"], + tags: ["stats", "metrics"], + category: "Objects", + description: "bar chart", + unicode_version: "6.0", + }, + { + emoji: "📋", + aliases: ["clipboard"], + tags: [], + category: "Objects", + description: "clipboard", + unicode_version: "6.0", + }, + { + emoji: "📌", + aliases: ["pushpin"], + tags: ["location"], + category: "Objects", + description: "pushpin", + unicode_version: "6.0", + }, + { + emoji: "📍", + aliases: ["round_pushpin"], + tags: ["location"], + category: "Objects", + description: "round pushpin", + unicode_version: "6.0", + }, + { + emoji: "📎", + aliases: ["paperclip"], + tags: [], + category: "Objects", + description: "paperclip", + unicode_version: "6.0", + }, + { + emoji: "🖇️", + aliases: ["paperclips"], + tags: [], + category: "Objects", + description: "linked paperclips", + unicode_version: "7.0", + }, + { + emoji: "📏", + aliases: ["straight_ruler"], + tags: [], + category: "Objects", + description: "straight ruler", + unicode_version: "6.0", + }, + { + emoji: "📐", + aliases: ["triangular_ruler"], + tags: [], + category: "Objects", + description: "triangular ruler", + unicode_version: "6.0", + }, + { + emoji: "✂️", + aliases: ["scissors"], + tags: ["cut"], + category: "Objects", + description: "scissors", + unicode_version: "", + }, + { + emoji: "🗃️", + aliases: ["card_file_box"], + tags: [], + category: "Objects", + description: "card file box", + unicode_version: "7.0", + }, + { + emoji: "🗄️", + aliases: ["file_cabinet"], + tags: [], + category: "Objects", + description: "file cabinet", + unicode_version: "7.0", + }, + { + emoji: "🗑️", + aliases: ["wastebasket"], + tags: ["trash"], + category: "Objects", + description: "wastebasket", + unicode_version: "7.0", + }, + { + emoji: "🔒", + aliases: ["lock"], + tags: ["security", "private"], + category: "Objects", + description: "locked", + unicode_version: "6.0", + }, + { + emoji: "🔓", + aliases: ["unlock"], + tags: ["security"], + category: "Objects", + description: "unlocked", + unicode_version: "6.0", + }, + { + emoji: "🔏", + aliases: ["lock_with_ink_pen"], + tags: [], + category: "Objects", + description: "locked with pen", + unicode_version: "6.0", + }, + { + emoji: "🔐", + aliases: ["closed_lock_with_key"], + tags: ["security"], + category: "Objects", + description: "locked with key", + unicode_version: "6.0", + }, + { + emoji: "🔑", + aliases: ["key"], + tags: ["lock", "password"], + category: "Objects", + description: "key", + unicode_version: "6.0", + }, + { + emoji: "🗝️", + aliases: ["old_key"], + tags: [], + category: "Objects", + description: "old key", + unicode_version: "7.0", + }, + { + emoji: "🔨", + aliases: ["hammer"], + tags: ["tool"], + category: "Objects", + description: "hammer", + unicode_version: "6.0", + }, + { + emoji: "🪓", + aliases: ["axe"], + tags: [], + category: "Objects", + description: "axe", + unicode_version: "12.0", + }, + { + emoji: "⛏️", + aliases: ["pick"], + tags: [], + category: "Objects", + description: "pick", + unicode_version: "5.2", + }, + { + emoji: "⚒️", + aliases: ["hammer_and_pick"], + tags: [], + category: "Objects", + description: "hammer and pick", + unicode_version: "4.1", + }, + { + emoji: "🛠️", + aliases: ["hammer_and_wrench"], + tags: [], + category: "Objects", + description: "hammer and wrench", + unicode_version: "7.0", + }, + { + emoji: "🗡️", + aliases: ["dagger"], + tags: [], + category: "Objects", + description: "dagger", + unicode_version: "7.0", + }, + { + emoji: "⚔️", + aliases: ["crossed_swords"], + tags: [], + category: "Objects", + description: "crossed swords", + unicode_version: "4.1", + }, + { + emoji: "🔫", + aliases: ["gun"], + tags: ["shoot", "weapon"], + category: "Objects", + description: "water pistol", + unicode_version: "6.0", + }, + { + emoji: "🪃", + aliases: ["boomerang"], + tags: [], + category: "Objects", + description: "boomerang", + unicode_version: "13.0", + }, + { + emoji: "🏹", + aliases: ["bow_and_arrow"], + tags: ["archery"], + category: "Objects", + description: "bow and arrow", + unicode_version: "8.0", + }, + { + emoji: "🛡️", + aliases: ["shield"], + tags: [], + category: "Objects", + description: "shield", + unicode_version: "7.0", + }, + { + emoji: "🪚", + aliases: ["carpentry_saw"], + tags: [], + category: "Objects", + description: "carpentry saw", + unicode_version: "13.0", + }, + { + emoji: "🔧", + aliases: ["wrench"], + tags: ["tool"], + category: "Objects", + description: "wrench", + unicode_version: "6.0", + }, + { + emoji: "🪛", + aliases: ["screwdriver"], + tags: [], + category: "Objects", + description: "screwdriver", + unicode_version: "13.0", + }, + { + emoji: "🔩", + aliases: ["nut_and_bolt"], + tags: [], + category: "Objects", + description: "nut and bolt", + unicode_version: "6.0", + }, + { + emoji: "⚙️", + aliases: ["gear"], + tags: [], + category: "Objects", + description: "gear", + unicode_version: "4.1", + }, + { + emoji: "🗜️", + aliases: ["clamp"], + tags: [], + category: "Objects", + description: "clamp", + unicode_version: "7.0", + }, + { + emoji: "⚖️", + aliases: ["balance_scale"], + tags: [], + category: "Objects", + description: "balance scale", + unicode_version: "4.1", + }, + { + emoji: "🦯", + aliases: ["probing_cane"], + tags: [], + category: "Objects", + description: "white cane", + unicode_version: "12.0", + }, + { + emoji: "🔗", + aliases: ["link"], + tags: [], + category: "Objects", + description: "link", + unicode_version: "6.0", + }, + { + emoji: "⛓️", + aliases: ["chains"], + tags: [], + category: "Objects", + description: "chains", + unicode_version: "5.2", + }, + { + emoji: "🪝", + aliases: ["hook"], + tags: [], + category: "Objects", + description: "hook", + unicode_version: "13.0", + }, + { + emoji: "🧰", + aliases: ["toolbox"], + tags: [], + category: "Objects", + description: "toolbox", + unicode_version: "11.0", + }, + { + emoji: "🧲", + aliases: ["magnet"], + tags: [], + category: "Objects", + description: "magnet", + unicode_version: "11.0", + }, + { + emoji: "🪜", + aliases: ["ladder"], + tags: [], + category: "Objects", + description: "ladder", + unicode_version: "13.0", + }, + { + emoji: "⚗️", + aliases: ["alembic"], + tags: [], + category: "Objects", + description: "alembic", + unicode_version: "4.1", + }, + { + emoji: "🧪", + aliases: ["test_tube"], + tags: [], + category: "Objects", + description: "test tube", + unicode_version: "11.0", + }, + { + emoji: "🧫", + aliases: ["petri_dish"], + tags: [], + category: "Objects", + description: "petri dish", + unicode_version: "11.0", + }, + { + emoji: "🧬", + aliases: ["dna"], + tags: [], + category: "Objects", + description: "dna", + unicode_version: "11.0", + }, + { + emoji: "🔬", + aliases: ["microscope"], + tags: ["science", "laboratory", "investigate"], + category: "Objects", + description: "microscope", + unicode_version: "6.0", + }, + { + emoji: "🔭", + aliases: ["telescope"], + tags: [], + category: "Objects", + description: "telescope", + unicode_version: "6.0", + }, + { + emoji: "📡", + aliases: ["satellite"], + tags: ["signal"], + category: "Objects", + description: "satellite antenna", + unicode_version: "6.0", + }, + { + emoji: "💉", + aliases: ["syringe"], + tags: ["health", "hospital", "needle"], + category: "Objects", + description: "syringe", + unicode_version: "6.0", + }, + { + emoji: "🩸", + aliases: ["drop_of_blood"], + tags: [], + category: "Objects", + description: "drop of blood", + unicode_version: "12.0", + }, + { + emoji: "💊", + aliases: ["pill"], + tags: ["health", "medicine"], + category: "Objects", + description: "pill", + unicode_version: "6.0", + }, + { + emoji: "🩹", + aliases: ["adhesive_bandage"], + tags: [], + category: "Objects", + description: "adhesive bandage", + unicode_version: "12.0", + }, + { + emoji: "🩺", + aliases: ["stethoscope"], + tags: [], + category: "Objects", + description: "stethoscope", + unicode_version: "12.0", + }, + { + emoji: "🚪", + aliases: ["door"], + tags: [], + category: "Objects", + description: "door", + unicode_version: "6.0", + }, + { + emoji: "🛗", + aliases: ["elevator"], + tags: [], + category: "Objects", + description: "elevator", + unicode_version: "13.0", + }, + { + emoji: "🪞", + aliases: ["mirror"], + tags: [], + category: "Objects", + description: "mirror", + unicode_version: "13.0", + }, + { + emoji: "🪟", + aliases: ["window"], + tags: [], + category: "Objects", + description: "window", + unicode_version: "13.0", + }, + { + emoji: "🛏️", + aliases: ["bed"], + tags: [], + category: "Objects", + description: "bed", + unicode_version: "7.0", + }, + { + emoji: "🛋️", + aliases: ["couch_and_lamp"], + tags: [], + category: "Objects", + description: "couch and lamp", + unicode_version: "7.0", + }, + { + emoji: "🪑", + aliases: ["chair"], + tags: [], + category: "Objects", + description: "chair", + unicode_version: "12.0", + }, + { + emoji: "🚽", + aliases: ["toilet"], + tags: ["wc"], + category: "Objects", + description: "toilet", + unicode_version: "6.0", + }, + { + emoji: "🪠", + aliases: ["plunger"], + tags: [], + category: "Objects", + description: "plunger", + unicode_version: "13.0", + }, + { + emoji: "🚿", + aliases: ["shower"], + tags: ["bath"], + category: "Objects", + description: "shower", + unicode_version: "6.0", + }, + { + emoji: "🛁", + aliases: ["bathtub"], + tags: [], + category: "Objects", + description: "bathtub", + unicode_version: "6.0", + }, + { + emoji: "🪤", + aliases: ["mouse_trap"], + tags: [], + category: "Objects", + description: "mouse trap", + unicode_version: "13.0", + }, + { + emoji: "🪒", + aliases: ["razor"], + tags: [], + category: "Objects", + description: "razor", + unicode_version: "12.0", + }, + { + emoji: "🧴", + aliases: ["lotion_bottle"], + tags: [], + category: "Objects", + description: "lotion bottle", + unicode_version: "11.0", + }, + { + emoji: "🧷", + aliases: ["safety_pin"], + tags: [], + category: "Objects", + description: "safety pin", + unicode_version: "11.0", + }, + { + emoji: "🧹", + aliases: ["broom"], + tags: [], + category: "Objects", + description: "broom", + unicode_version: "11.0", + }, + { + emoji: "🧺", + aliases: ["basket"], + tags: [], + category: "Objects", + description: "basket", + unicode_version: "11.0", + }, + { + emoji: "🧻", + aliases: ["roll_of_paper"], + tags: ["toilet"], + category: "Objects", + description: "roll of paper", + unicode_version: "11.0", + }, + { + emoji: "🪣", + aliases: ["bucket"], + tags: [], + category: "Objects", + description: "bucket", + unicode_version: "13.0", + }, + { + emoji: "🧼", + aliases: ["soap"], + tags: [], + category: "Objects", + description: "soap", + unicode_version: "11.0", + }, + { + emoji: "🪥", + aliases: ["toothbrush"], + tags: [], + category: "Objects", + description: "toothbrush", + unicode_version: "13.0", + }, + { + emoji: "🧽", + aliases: ["sponge"], + tags: [], + category: "Objects", + description: "sponge", + unicode_version: "11.0", + }, + { + emoji: "🧯", + aliases: ["fire_extinguisher"], + tags: [], + category: "Objects", + description: "fire extinguisher", + unicode_version: "11.0", + }, + { + emoji: "🛒", + aliases: ["shopping_cart"], + tags: [], + category: "Objects", + description: "shopping cart", + unicode_version: "9.0", + }, + { + emoji: "🚬", + aliases: ["smoking"], + tags: ["cigarette"], + category: "Objects", + description: "cigarette", + unicode_version: "6.0", + }, + { + emoji: "⚰️", + aliases: ["coffin"], + tags: ["funeral"], + category: "Objects", + description: "coffin", + unicode_version: "4.1", + }, + { + emoji: "🪦", + aliases: ["headstone"], + tags: [], + category: "Objects", + description: "headstone", + unicode_version: "13.0", + }, + { + emoji: "⚱️", + aliases: ["funeral_urn"], + tags: [], + category: "Objects", + description: "funeral urn", + unicode_version: "4.1", + }, + { + emoji: "🗿", + aliases: ["moyai"], + tags: ["stone"], + category: "Objects", + description: "moai", + unicode_version: "6.0", + }, + { + emoji: "🪧", + aliases: ["placard"], + tags: [], + category: "Objects", + description: "placard", + unicode_version: "13.0", + }, + { + emoji: "🏧", + aliases: ["atm"], + tags: [], + category: "Symbols", + description: "ATM sign", + unicode_version: "6.0", + }, + { + emoji: "🚮", + aliases: ["put_litter_in_its_place"], + tags: [], + category: "Symbols", + description: "litter in bin sign", + unicode_version: "6.0", + }, + { + emoji: "🚰", + aliases: ["potable_water"], + tags: [], + category: "Symbols", + description: "potable water", + unicode_version: "6.0", + }, + { + emoji: "♿", + aliases: ["wheelchair"], + tags: ["accessibility"], + category: "Symbols", + description: "wheelchair symbol", + unicode_version: "4.1", + }, + { + emoji: "🚹", + aliases: ["mens"], + tags: [], + category: "Symbols", + description: "men’s room", + unicode_version: "6.0", + }, + { + emoji: "🚺", + aliases: ["womens"], + tags: [], + category: "Symbols", + description: "women’s room", + unicode_version: "6.0", + }, + { + emoji: "🚻", + aliases: ["restroom"], + tags: ["toilet"], + category: "Symbols", + description: "restroom", + unicode_version: "6.0", + }, + { + emoji: "🚼", + aliases: ["baby_symbol"], + tags: [], + category: "Symbols", + description: "baby symbol", + unicode_version: "6.0", + }, + { + emoji: "🚾", + aliases: ["wc"], + tags: ["toilet", "restroom"], + category: "Symbols", + description: "water closet", + unicode_version: "6.0", + }, + { + emoji: "🛂", + aliases: ["passport_control"], + tags: [], + category: "Symbols", + description: "passport control", + unicode_version: "6.0", + }, + { + emoji: "🛃", + aliases: ["customs"], + tags: [], + category: "Symbols", + description: "customs", + unicode_version: "6.0", + }, + { + emoji: "🛄", + aliases: ["baggage_claim"], + tags: ["airport"], + category: "Symbols", + description: "baggage claim", + unicode_version: "6.0", + }, + { + emoji: "🛅", + aliases: ["left_luggage"], + tags: [], + category: "Symbols", + description: "left luggage", + unicode_version: "6.0", + }, + { + emoji: "⚠️", + aliases: ["warning"], + tags: ["wip"], + category: "Symbols", + description: "warning", + unicode_version: "4.0", + }, + { + emoji: "🚸", + aliases: ["children_crossing"], + tags: [], + category: "Symbols", + description: "children crossing", + unicode_version: "6.0", + }, + { + emoji: "⛔", + aliases: ["no_entry"], + tags: ["limit"], + category: "Symbols", + description: "no entry", + unicode_version: "5.2", + }, + { + emoji: "🚫", + aliases: ["no_entry_sign"], + tags: ["block", "forbidden"], + category: "Symbols", + description: "prohibited", + unicode_version: "6.0", + }, + { + emoji: "🚳", + aliases: ["no_bicycles"], + tags: [], + category: "Symbols", + description: "no bicycles", + unicode_version: "6.0", + }, + { + emoji: "🚭", + aliases: ["no_smoking"], + tags: [], + category: "Symbols", + description: "no smoking", + unicode_version: "6.0", + }, + { + emoji: "🚯", + aliases: ["do_not_litter"], + tags: [], + category: "Symbols", + description: "no littering", + unicode_version: "6.0", + }, + { + emoji: "🚱", + aliases: ["non-potable_water"], + tags: [], + category: "Symbols", + description: "non-potable water", + unicode_version: "6.0", + }, + { + emoji: "🚷", + aliases: ["no_pedestrians"], + tags: [], + category: "Symbols", + description: "no pedestrians", + unicode_version: "6.0", + }, + { + emoji: "📵", + aliases: ["no_mobile_phones"], + tags: [], + category: "Symbols", + description: "no mobile phones", + unicode_version: "6.0", + }, + { + emoji: "🔞", + aliases: ["underage"], + tags: [], + category: "Symbols", + description: "no one under eighteen", + unicode_version: "6.0", + }, + { + emoji: "☢️", + aliases: ["radioactive"], + tags: [], + category: "Symbols", + description: "radioactive", + unicode_version: "", + }, + { + emoji: "☣️", + aliases: ["biohazard"], + tags: [], + category: "Symbols", + description: "biohazard", + unicode_version: "", + }, + { + emoji: "⬆️", + aliases: ["arrow_up"], + tags: [], + category: "Symbols", + description: "up arrow", + unicode_version: "4.0", + }, + { + emoji: "↗️", + aliases: ["arrow_upper_right"], + tags: [], + category: "Symbols", + description: "up-right arrow", + unicode_version: "", + }, + { + emoji: "➡️", + aliases: ["arrow_right"], + tags: [], + category: "Symbols", + description: "right arrow", + unicode_version: "", + }, + { + emoji: "↘️", + aliases: ["arrow_lower_right"], + tags: [], + category: "Symbols", + description: "down-right arrow", + unicode_version: "", + }, + { + emoji: "⬇️", + aliases: ["arrow_down"], + tags: [], + category: "Symbols", + description: "down arrow", + unicode_version: "4.0", + }, + { + emoji: "↙️", + aliases: ["arrow_lower_left"], + tags: [], + category: "Symbols", + description: "down-left arrow", + unicode_version: "", + }, + { + emoji: "⬅️", + aliases: ["arrow_left"], + tags: [], + category: "Symbols", + description: "left arrow", + unicode_version: "4.0", + }, + { + emoji: "↖️", + aliases: ["arrow_upper_left"], + tags: [], + category: "Symbols", + description: "up-left arrow", + unicode_version: "", + }, + { + emoji: "↕️", + aliases: ["arrow_up_down"], + tags: [], + category: "Symbols", + description: "up-down arrow", + unicode_version: "", + }, + { + emoji: "↔️", + aliases: ["left_right_arrow"], + tags: [], + category: "Symbols", + description: "left-right arrow", + unicode_version: "", + }, + { + emoji: "↩️", + aliases: ["leftwards_arrow_with_hook"], + tags: ["return"], + category: "Symbols", + description: "right arrow curving left", + unicode_version: "", + }, + { + emoji: "↪️", + aliases: ["arrow_right_hook"], + tags: [], + category: "Symbols", + description: "left arrow curving right", + unicode_version: "", + }, + { + emoji: "⤴️", + aliases: ["arrow_heading_up"], + tags: [], + category: "Symbols", + description: "right arrow curving up", + unicode_version: "", + }, + { + emoji: "⤵️", + aliases: ["arrow_heading_down"], + tags: [], + category: "Symbols", + description: "right arrow curving down", + unicode_version: "", + }, + { + emoji: "🔃", + aliases: ["arrows_clockwise"], + tags: [], + category: "Symbols", + description: "clockwise vertical arrows", + unicode_version: "6.0", + }, + { + emoji: "🔄", + aliases: ["arrows_counterclockwise"], + tags: ["sync"], + category: "Symbols", + description: "counterclockwise arrows button", + unicode_version: "6.0", + }, + { + emoji: "🔙", + aliases: ["back"], + tags: [], + category: "Symbols", + description: "BACK arrow", + unicode_version: "6.0", + }, + { + emoji: "🔚", + aliases: ["end"], + tags: [], + category: "Symbols", + description: "END arrow", + unicode_version: "6.0", + }, + { + emoji: "🔛", + aliases: ["on"], + tags: [], + category: "Symbols", + description: "ON! arrow", + unicode_version: "6.0", + }, + { + emoji: "🔜", + aliases: ["soon"], + tags: [], + category: "Symbols", + description: "SOON arrow", + unicode_version: "6.0", + }, + { + emoji: "🔝", + aliases: ["top"], + tags: [], + category: "Symbols", + description: "TOP arrow", + unicode_version: "6.0", + }, + { + emoji: "🛐", + aliases: ["place_of_worship"], + tags: [], + category: "Symbols", + description: "place of worship", + unicode_version: "8.0", + }, + { + emoji: "⚛️", + aliases: ["atom_symbol"], + tags: [], + category: "Symbols", + description: "atom symbol", + unicode_version: "4.1", + }, + { + emoji: "🕉️", + aliases: ["om"], + tags: [], + category: "Symbols", + description: "om", + unicode_version: "7.0", + }, + { + emoji: "✡️", + aliases: ["star_of_david"], + tags: [], + category: "Symbols", + description: "star of David", + unicode_version: "", + }, + { + emoji: "☸️", + aliases: ["wheel_of_dharma"], + tags: [], + category: "Symbols", + description: "wheel of dharma", + unicode_version: "", + }, + { + emoji: "☯️", + aliases: ["yin_yang"], + tags: [], + category: "Symbols", + description: "yin yang", + unicode_version: "", + }, + { + emoji: "✝️", + aliases: ["latin_cross"], + tags: [], + category: "Symbols", + description: "latin cross", + unicode_version: "", + }, + { + emoji: "☦️", + aliases: ["orthodox_cross"], + tags: [], + category: "Symbols", + description: "orthodox cross", + unicode_version: "", + }, + { + emoji: "☪️", + aliases: ["star_and_crescent"], + tags: [], + category: "Symbols", + description: "star and crescent", + unicode_version: "", + }, + { + emoji: "☮️", + aliases: ["peace_symbol"], + tags: [], + category: "Symbols", + description: "peace symbol", + unicode_version: "", + }, + { + emoji: "🕎", + aliases: ["menorah"], + tags: [], + category: "Symbols", + description: "menorah", + unicode_version: "8.0", + }, + { + emoji: "🔯", + aliases: ["six_pointed_star"], + tags: [], + category: "Symbols", + description: "dotted six-pointed star", + unicode_version: "6.0", + }, + { + emoji: "♈", + aliases: ["aries"], + tags: [], + category: "Symbols", + description: "Aries", + unicode_version: "", + }, + { + emoji: "♉", + aliases: ["taurus"], + tags: [], + category: "Symbols", + description: "Taurus", + unicode_version: "", + }, + { + emoji: "♊", + aliases: ["gemini"], + tags: [], + category: "Symbols", + description: "Gemini", + unicode_version: "", + }, + { + emoji: "♋", + aliases: ["cancer"], + tags: [], + category: "Symbols", + description: "Cancer", + unicode_version: "", + }, + { + emoji: "♌", + aliases: ["leo"], + tags: [], + category: "Symbols", + description: "Leo", + unicode_version: "", + }, + { + emoji: "♍", + aliases: ["virgo"], + tags: [], + category: "Symbols", + description: "Virgo", + unicode_version: "", + }, + { + emoji: "♎", + aliases: ["libra"], + tags: [], + category: "Symbols", + description: "Libra", + unicode_version: "", + }, + { + emoji: "♏", + aliases: ["scorpius"], + tags: [], + category: "Symbols", + description: "Scorpio", + unicode_version: "", + }, + { + emoji: "♐", + aliases: ["sagittarius"], + tags: [], + category: "Symbols", + description: "Sagittarius", + unicode_version: "", + }, + { + emoji: "♑", + aliases: ["capricorn"], + tags: [], + category: "Symbols", + description: "Capricorn", + unicode_version: "", + }, + { + emoji: "♒", + aliases: ["aquarius"], + tags: [], + category: "Symbols", + description: "Aquarius", + unicode_version: "", + }, + { + emoji: "♓", + aliases: ["pisces"], + tags: [], + category: "Symbols", + description: "Pisces", + unicode_version: "", + }, + { + emoji: "⛎", + aliases: ["ophiuchus"], + tags: [], + category: "Symbols", + description: "Ophiuchus", + unicode_version: "6.0", + }, + { + emoji: "🔀", + aliases: ["twisted_rightwards_arrows"], + tags: ["shuffle"], + category: "Symbols", + description: "shuffle tracks button", + unicode_version: "6.0", + }, + { + emoji: "🔁", + aliases: ["repeat"], + tags: ["loop"], + category: "Symbols", + description: "repeat button", + unicode_version: "6.0", + }, + { + emoji: "🔂", + aliases: ["repeat_one"], + tags: [], + category: "Symbols", + description: "repeat single button", + unicode_version: "6.0", + }, + { + emoji: "▶️", + aliases: ["arrow_forward"], + tags: [], + category: "Symbols", + description: "play button", + unicode_version: "", + }, + { + emoji: "⏩", + aliases: ["fast_forward"], + tags: [], + category: "Symbols", + description: "fast-forward button", + unicode_version: "6.0", + }, + { + emoji: "⏭️", + aliases: ["next_track_button"], + tags: [], + category: "Symbols", + description: "next track button", + unicode_version: "6.0", + }, + { + emoji: "⏯️", + aliases: ["play_or_pause_button"], + tags: [], + category: "Symbols", + description: "play or pause button", + unicode_version: "6.0", + }, + { + emoji: "◀️", + aliases: ["arrow_backward"], + tags: [], + category: "Symbols", + description: "reverse button", + unicode_version: "", + }, + { + emoji: "⏪", + aliases: ["rewind"], + tags: [], + category: "Symbols", + description: "fast reverse button", + unicode_version: "6.0", + }, + { + emoji: "⏮️", + aliases: ["previous_track_button"], + tags: [], + category: "Symbols", + description: "last track button", + unicode_version: "6.0", + }, + { + emoji: "🔼", + aliases: ["arrow_up_small"], + tags: [], + category: "Symbols", + description: "upwards button", + unicode_version: "6.0", + }, + { + emoji: "⏫", + aliases: ["arrow_double_up"], + tags: [], + category: "Symbols", + description: "fast up button", + unicode_version: "6.0", + }, + { + emoji: "🔽", + aliases: ["arrow_down_small"], + tags: [], + category: "Symbols", + description: "downwards button", + unicode_version: "6.0", + }, + { + emoji: "⏬", + aliases: ["arrow_double_down"], + tags: [], + category: "Symbols", + description: "fast down button", + unicode_version: "6.0", + }, + { + emoji: "⏸️", + aliases: ["pause_button"], + tags: [], + category: "Symbols", + description: "pause button", + unicode_version: "7.0", + }, + { + emoji: "⏹️", + aliases: ["stop_button"], + tags: [], + category: "Symbols", + description: "stop button", + unicode_version: "7.0", + }, + { + emoji: "⏺️", + aliases: ["record_button"], + tags: [], + category: "Symbols", + description: "record button", + unicode_version: "7.0", + }, + { + emoji: "⏏️", + aliases: ["eject_button"], + tags: [], + category: "Symbols", + description: "eject button", + unicode_version: "11.0", + }, + { + emoji: "🎦", + aliases: ["cinema"], + tags: ["film", "movie"], + category: "Symbols", + description: "cinema", + unicode_version: "6.0", + }, + { + emoji: "🔅", + aliases: ["low_brightness"], + tags: [], + category: "Symbols", + description: "dim button", + unicode_version: "6.0", + }, + { + emoji: "🔆", + aliases: ["high_brightness"], + tags: [], + category: "Symbols", + description: "bright button", + unicode_version: "6.0", + }, + { + emoji: "📶", + aliases: ["signal_strength"], + tags: ["wifi"], + category: "Symbols", + description: "antenna bars", + unicode_version: "6.0", + }, + { + emoji: "📳", + aliases: ["vibration_mode"], + tags: [], + category: "Symbols", + description: "vibration mode", + unicode_version: "6.0", + }, + { + emoji: "📴", + aliases: ["mobile_phone_off"], + tags: ["mute", "off"], + category: "Symbols", + description: "mobile phone off", + unicode_version: "6.0", + }, + { + emoji: "♀️", + aliases: ["female_sign"], + tags: [], + category: "Symbols", + description: "female sign", + unicode_version: "11.0", + }, + { + emoji: "♂️", + aliases: ["male_sign"], + tags: [], + category: "Symbols", + description: "male sign", + unicode_version: "11.0", + }, + { + emoji: "⚧️", + aliases: ["transgender_symbol"], + tags: [], + category: "Symbols", + description: "transgender symbol", + unicode_version: "13.0", + }, + { + emoji: "✖️", + aliases: ["heavy_multiplication_x"], + tags: [], + category: "Symbols", + description: "multiply", + unicode_version: "", + }, + { + emoji: "➕", + aliases: ["heavy_plus_sign"], + tags: [], + category: "Symbols", + description: "plus", + unicode_version: "6.0", + }, + { + emoji: "➖", + aliases: ["heavy_minus_sign"], + tags: [], + category: "Symbols", + description: "minus", + unicode_version: "6.0", + }, + { + emoji: "➗", + aliases: ["heavy_division_sign"], + tags: [], + category: "Symbols", + description: "divide", + unicode_version: "6.0", + }, + { + emoji: "♾️", + aliases: ["infinity"], + tags: [], + category: "Symbols", + description: "infinity", + unicode_version: "11.0", + }, + { + emoji: "‼️", + aliases: ["bangbang"], + tags: [], + category: "Symbols", + description: "double exclamation mark", + unicode_version: "", + }, + { + emoji: "⁉️", + aliases: ["interrobang"], + tags: [], + category: "Symbols", + description: "exclamation question mark", + unicode_version: "3.0", + }, + { + emoji: "❓", + aliases: ["question"], + tags: ["confused"], + category: "Symbols", + description: "red question mark", + unicode_version: "6.0", + }, + { + emoji: "❔", + aliases: ["grey_question"], + tags: [], + category: "Symbols", + description: "white question mark", + unicode_version: "6.0", + }, + { + emoji: "❕", + aliases: ["grey_exclamation"], + tags: [], + category: "Symbols", + description: "white exclamation mark", + unicode_version: "6.0", + }, + { + emoji: "❗", + aliases: ["exclamation", "heavy_exclamation_mark"], + tags: ["bang"], + category: "Symbols", + description: "red exclamation mark", + unicode_version: "5.2", + }, + { + emoji: "〰️", + aliases: ["wavy_dash"], + tags: [], + category: "Symbols", + description: "wavy dash", + unicode_version: "", + }, + { + emoji: "💱", + aliases: ["currency_exchange"], + tags: [], + category: "Symbols", + description: "currency exchange", + unicode_version: "6.0", + }, + { + emoji: "💲", + aliases: ["heavy_dollar_sign"], + tags: [], + category: "Symbols", + description: "heavy dollar sign", + unicode_version: "6.0", + }, + { + emoji: "⚕️", + aliases: ["medical_symbol"], + tags: [], + category: "Symbols", + description: "medical symbol", + unicode_version: "11.0", + }, + { + emoji: "♻️", + aliases: ["recycle"], + tags: ["environment", "green"], + category: "Symbols", + description: "recycling symbol", + unicode_version: "3.2", + }, + { + emoji: "⚜️", + aliases: ["fleur_de_lis"], + tags: [], + category: "Symbols", + description: "fleur-de-lis", + unicode_version: "4.1", + }, + { + emoji: "🔱", + aliases: ["trident"], + tags: [], + category: "Symbols", + description: "trident emblem", + unicode_version: "6.0", + }, + { + emoji: "📛", + aliases: ["name_badge"], + tags: [], + category: "Symbols", + description: "name badge", + unicode_version: "6.0", + }, + { + emoji: "🔰", + aliases: ["beginner"], + tags: [], + category: "Symbols", + description: "Japanese symbol for beginner", + unicode_version: "6.0", + }, + { + emoji: "⭕", + aliases: ["o"], + tags: [], + category: "Symbols", + description: "hollow red circle", + unicode_version: "5.2", + }, + { + emoji: "✅", + aliases: ["white_check_mark"], + tags: [], + category: "Symbols", + description: "check mark button", + unicode_version: "6.0", + }, + { + emoji: "☑️", + aliases: ["ballot_box_with_check"], + tags: [], + category: "Symbols", + description: "check box with check", + unicode_version: "", + }, + { + emoji: "✔️", + aliases: ["heavy_check_mark"], + tags: [], + category: "Symbols", + description: "check mark", + unicode_version: "", + }, + { + emoji: "❌", + aliases: ["x"], + tags: [], + category: "Symbols", + description: "cross mark", + unicode_version: "6.0", + }, + { + emoji: "❎", + aliases: ["negative_squared_cross_mark"], + tags: [], + category: "Symbols", + description: "cross mark button", + unicode_version: "6.0", + }, + { + emoji: "➰", + aliases: ["curly_loop"], + tags: [], + category: "Symbols", + description: "curly loop", + unicode_version: "6.0", + }, + { + emoji: "➿", + aliases: ["loop"], + tags: [], + category: "Symbols", + description: "double curly loop", + unicode_version: "6.0", + }, + { + emoji: "〽️", + aliases: ["part_alternation_mark"], + tags: [], + category: "Symbols", + description: "part alternation mark", + unicode_version: "3.2", + }, + { + emoji: "✳️", + aliases: ["eight_spoked_asterisk"], + tags: [], + category: "Symbols", + description: "eight-spoked asterisk", + unicode_version: "", + }, + { + emoji: "✴️", + aliases: ["eight_pointed_black_star"], + tags: [], + category: "Symbols", + description: "eight-pointed star", + unicode_version: "", + }, + { + emoji: "❇️", + aliases: ["sparkle"], + tags: [], + category: "Symbols", + description: "sparkle", + unicode_version: "", + }, + { + emoji: "©️", + aliases: ["copyright"], + tags: [], + category: "Symbols", + description: "copyright", + unicode_version: "", + }, + { + emoji: "®️", + aliases: ["registered"], + tags: [], + category: "Symbols", + description: "registered", + unicode_version: "", + }, + { + emoji: "™️", + aliases: ["tm"], + tags: ["trademark"], + category: "Symbols", + description: "trade mark", + unicode_version: "", + }, + { + emoji: "#️⃣", + aliases: ["hash"], + tags: ["number"], + category: "Symbols", + description: "keycap: #", + unicode_version: "", + }, + { + emoji: "*️⃣", + aliases: ["asterisk"], + tags: [], + category: "Symbols", + description: "keycap: *", + unicode_version: "", + }, + { + emoji: "0️⃣", + aliases: ["zero"], + tags: [], + category: "Symbols", + description: "keycap: 0", + unicode_version: "", + }, + { + emoji: "1️⃣", + aliases: ["one"], + tags: [], + category: "Symbols", + description: "keycap: 1", + unicode_version: "", + }, + { + emoji: "2️⃣", + aliases: ["two"], + tags: [], + category: "Symbols", + description: "keycap: 2", + unicode_version: "", + }, + { + emoji: "3️⃣", + aliases: ["three"], + tags: [], + category: "Symbols", + description: "keycap: 3", + unicode_version: "", + }, + { + emoji: "4️⃣", + aliases: ["four"], + tags: [], + category: "Symbols", + description: "keycap: 4", + unicode_version: "", + }, + { + emoji: "5️⃣", + aliases: ["five"], + tags: [], + category: "Symbols", + description: "keycap: 5", + unicode_version: "", + }, + { + emoji: "6️⃣", + aliases: ["six"], + tags: [], + category: "Symbols", + description: "keycap: 6", + unicode_version: "", + }, + { + emoji: "7️⃣", + aliases: ["seven"], + tags: [], + category: "Symbols", + description: "keycap: 7", + unicode_version: "", + }, + { + emoji: "8️⃣", + aliases: ["eight"], + tags: [], + category: "Symbols", + description: "keycap: 8", + unicode_version: "", + }, + { + emoji: "9️⃣", + aliases: ["nine"], + tags: [], + category: "Symbols", + description: "keycap: 9", + unicode_version: "", + }, + { + emoji: "🔟", + aliases: ["keycap_ten"], + tags: [], + category: "Symbols", + description: "keycap: 10", + unicode_version: "6.0", + }, + { + emoji: "🔠", + aliases: ["capital_abcd"], + tags: ["letters"], + category: "Symbols", + description: "input latin uppercase", + unicode_version: "6.0", + }, + { + emoji: "🔡", + aliases: ["abcd"], + tags: [], + category: "Symbols", + description: "input latin lowercase", + unicode_version: "6.0", + }, + { + emoji: "🔢", + aliases: ["1234"], + tags: ["numbers"], + category: "Symbols", + description: "input numbers", + unicode_version: "6.0", + }, + { + emoji: "🔣", + aliases: ["symbols"], + tags: [], + category: "Symbols", + description: "input symbols", + unicode_version: "6.0", + }, + { + emoji: "🔤", + aliases: ["abc"], + tags: ["alphabet"], + category: "Symbols", + description: "input latin letters", + unicode_version: "6.0", + }, + { + emoji: "🅰️", + aliases: ["a"], + tags: [], + category: "Symbols", + description: "A button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆎", + aliases: ["ab"], + tags: [], + category: "Symbols", + description: "AB button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🅱️", + aliases: ["b"], + tags: [], + category: "Symbols", + description: "B button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆑", + aliases: ["cl"], + tags: [], + category: "Symbols", + description: "CL button", + unicode_version: "6.0", + }, + { + emoji: "🆒", + aliases: ["cool"], + tags: [], + category: "Symbols", + description: "COOL button", + unicode_version: "6.0", + }, + { + emoji: "🆓", + aliases: ["free"], + tags: [], + category: "Symbols", + description: "FREE button", + unicode_version: "6.0", + }, + { + emoji: "ℹ️", + aliases: ["information_source"], + tags: [], + category: "Symbols", + description: "information", + unicode_version: "3.0", + }, + { + emoji: "🆔", + aliases: ["id"], + tags: [], + category: "Symbols", + description: "ID button", + unicode_version: "6.0", + }, + { + emoji: "Ⓜ️", + aliases: ["m"], + tags: [], + category: "Symbols", + description: "circled M", + unicode_version: "", + }, + { + emoji: "🆕", + aliases: ["new"], + tags: ["fresh"], + category: "Symbols", + description: "NEW button", + unicode_version: "6.0", + }, + { + emoji: "🆖", + aliases: ["ng"], + tags: [], + category: "Symbols", + description: "NG button", + unicode_version: "6.0", + }, + { + emoji: "🅾️", + aliases: ["o2"], + tags: [], + category: "Symbols", + description: "O button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆗", + aliases: ["ok"], + tags: ["yes"], + category: "Symbols", + description: "OK button", + unicode_version: "6.0", + }, + { + emoji: "🅿️", + aliases: ["parking"], + tags: [], + category: "Symbols", + description: "P button", + unicode_version: "5.2", + }, + { + emoji: "🆘", + aliases: ["sos"], + tags: ["help", "emergency"], + category: "Symbols", + description: "SOS button", + unicode_version: "6.0", + }, + { + emoji: "🆙", + aliases: ["up"], + tags: [], + category: "Symbols", + description: "UP! button", + unicode_version: "6.0", + }, + { + emoji: "🆚", + aliases: ["vs"], + tags: [], + category: "Symbols", + description: "VS button", + unicode_version: "6.0", + }, + { + emoji: "🈁", + aliases: ["koko"], + tags: [], + category: "Symbols", + description: "Japanese “here” button", + unicode_version: "6.0", + }, + { + emoji: "🈂️", + aliases: ["sa"], + tags: [], + category: "Symbols", + description: "Japanese “service charge” button", + unicode_version: "6.0", + }, + { + emoji: "🈷️", + aliases: ["u6708"], + tags: [], + category: "Symbols", + description: "Japanese “monthly amount” button", + unicode_version: "6.0", + }, + { + emoji: "🈶", + aliases: ["u6709"], + tags: [], + category: "Symbols", + description: "Japanese “not free of charge” button", + unicode_version: "6.0", + }, + { + emoji: "🈯", + aliases: ["u6307"], + tags: [], + category: "Symbols", + description: "Japanese “reserved” button", + unicode_version: "", + }, + { + emoji: "🉐", + aliases: ["ideograph_advantage"], + tags: [], + category: "Symbols", + description: "Japanese “bargain” button", + unicode_version: "6.0", + }, + { + emoji: "🈹", + aliases: ["u5272"], + tags: [], + category: "Symbols", + description: "Japanese “discount” button", + unicode_version: "6.0", + }, + { + emoji: "🈚", + aliases: ["u7121"], + tags: [], + category: "Symbols", + description: "Japanese “free of charge” button", + unicode_version: "", + }, + { + emoji: "🈲", + aliases: ["u7981"], + tags: [], + category: "Symbols", + description: "Japanese “prohibited” button", + unicode_version: "6.0", + }, + { + emoji: "🉑", + aliases: ["accept"], + tags: [], + category: "Symbols", + description: "Japanese “acceptable” button", + unicode_version: "6.0", + }, + { + emoji: "🈸", + aliases: ["u7533"], + tags: [], + category: "Symbols", + description: "Japanese “application” button", + unicode_version: "6.0", + }, + { + emoji: "🈴", + aliases: ["u5408"], + tags: [], + category: "Symbols", + description: "Japanese “passing grade” button", + unicode_version: "6.0", + }, + { + emoji: "🈳", + aliases: ["u7a7a"], + tags: [], + category: "Symbols", + description: "Japanese “vacancy” button", + unicode_version: "6.0", + }, + { + emoji: "㊗️", + aliases: ["congratulations"], + tags: [], + category: "Symbols", + description: "Japanese “congratulations” button", + unicode_version: "", + }, + { + emoji: "㊙️", + aliases: ["secret"], + tags: [], + category: "Symbols", + description: "Japanese “secret” button", + unicode_version: "", + }, + { + emoji: "🈺", + aliases: ["u55b6"], + tags: [], + category: "Symbols", + description: "Japanese “open for business” button", + unicode_version: "6.0", + }, + { + emoji: "🈵", + aliases: ["u6e80"], + tags: [], + category: "Symbols", + description: "Japanese “no vacancy” button", + unicode_version: "6.0", + }, + { + emoji: "🔴", + aliases: ["red_circle"], + tags: [], + category: "Symbols", + description: "red circle", + unicode_version: "6.0", + }, + { + emoji: "🟠", + aliases: ["orange_circle"], + tags: [], + category: "Symbols", + description: "orange circle", + unicode_version: "12.0", + }, + { + emoji: "🟡", + aliases: ["yellow_circle"], + tags: [], + category: "Symbols", + description: "yellow circle", + unicode_version: "12.0", + }, + { + emoji: "🟢", + aliases: ["green_circle"], + tags: [], + category: "Symbols", + description: "green circle", + unicode_version: "12.0", + }, + { + emoji: "🔵", + aliases: ["large_blue_circle"], + tags: [], + category: "Symbols", + description: "blue circle", + unicode_version: "6.0", + }, + { + emoji: "🟣", + aliases: ["purple_circle"], + tags: [], + category: "Symbols", + description: "purple circle", + unicode_version: "12.0", + }, + { + emoji: "🟤", + aliases: ["brown_circle"], + tags: [], + category: "Symbols", + description: "brown circle", + unicode_version: "12.0", + }, + { + emoji: "⚫", + aliases: ["black_circle"], + tags: [], + category: "Symbols", + description: "black circle", + unicode_version: "4.1", + }, + { + emoji: "⚪", + aliases: ["white_circle"], + tags: [], + category: "Symbols", + description: "white circle", + unicode_version: "4.1", + }, + { + emoji: "🟥", + aliases: ["red_square"], + tags: [], + category: "Symbols", + description: "red square", + unicode_version: "12.0", + }, + { + emoji: "🟧", + aliases: ["orange_square"], + tags: [], + category: "Symbols", + description: "orange square", + unicode_version: "12.0", + }, + { + emoji: "🟨", + aliases: ["yellow_square"], + tags: [], + category: "Symbols", + description: "yellow square", + unicode_version: "12.0", + }, + { + emoji: "🟩", + aliases: ["green_square"], + tags: [], + category: "Symbols", + description: "green square", + unicode_version: "12.0", + }, + { + emoji: "🟦", + aliases: ["blue_square"], + tags: [], + category: "Symbols", + description: "blue square", + unicode_version: "12.0", + }, + { + emoji: "🟪", + aliases: ["purple_square"], + tags: [], + category: "Symbols", + description: "purple square", + unicode_version: "12.0", + }, + { + emoji: "🟫", + aliases: ["brown_square"], + tags: [], + category: "Symbols", + description: "brown square", + unicode_version: "12.0", + }, + { + emoji: "⬛", + aliases: ["black_large_square"], + tags: [], + category: "Symbols", + description: "black large square", + unicode_version: "5.1", + }, + { + emoji: "⬜", + aliases: ["white_large_square"], + tags: [], + category: "Symbols", + description: "white large square", + unicode_version: "5.1", + }, + { + emoji: "◼️", + aliases: ["black_medium_square"], + tags: [], + category: "Symbols", + description: "black medium square", + unicode_version: "3.2", + }, + { + emoji: "◻️", + aliases: ["white_medium_square"], + tags: [], + category: "Symbols", + description: "white medium square", + unicode_version: "3.2", + }, + { + emoji: "◾", + aliases: ["black_medium_small_square"], + tags: [], + category: "Symbols", + description: "black medium-small square", + unicode_version: "3.2", + }, + { + emoji: "◽", + aliases: ["white_medium_small_square"], + tags: [], + category: "Symbols", + description: "white medium-small square", + unicode_version: "3.2", + }, + { + emoji: "▪️", + aliases: ["black_small_square"], + tags: [], + category: "Symbols", + description: "black small square", + unicode_version: "", + }, + { + emoji: "▫️", + aliases: ["white_small_square"], + tags: [], + category: "Symbols", + description: "white small square", + unicode_version: "", + }, + { + emoji: "🔶", + aliases: ["large_orange_diamond"], + tags: [], + category: "Symbols", + description: "large orange diamond", + unicode_version: "6.0", + }, + { + emoji: "🔷", + aliases: ["large_blue_diamond"], + tags: [], + category: "Symbols", + description: "large blue diamond", + unicode_version: "6.0", + }, + { + emoji: "🔸", + aliases: ["small_orange_diamond"], + tags: [], + category: "Symbols", + description: "small orange diamond", + unicode_version: "6.0", + }, + { + emoji: "🔹", + aliases: ["small_blue_diamond"], + tags: [], + category: "Symbols", + description: "small blue diamond", + unicode_version: "6.0", + }, + { + emoji: "🔺", + aliases: ["small_red_triangle"], + tags: [], + category: "Symbols", + description: "red triangle pointed up", + unicode_version: "6.0", + }, + { + emoji: "🔻", + aliases: ["small_red_triangle_down"], + tags: [], + category: "Symbols", + description: "red triangle pointed down", + unicode_version: "6.0", + }, + { + emoji: "💠", + aliases: ["diamond_shape_with_a_dot_inside"], + tags: [], + category: "Symbols", + description: "diamond with a dot", + unicode_version: "6.0", + }, + { + emoji: "🔘", + aliases: ["radio_button"], + tags: [], + category: "Symbols", + description: "radio button", + unicode_version: "6.0", + }, + { + emoji: "🔳", + aliases: ["white_square_button"], + tags: [], + category: "Symbols", + description: "white square button", + unicode_version: "6.0", + }, + { + emoji: "🔲", + aliases: ["black_square_button"], + tags: [], + category: "Symbols", + description: "black square button", + unicode_version: "6.0", + }, + { + emoji: "🏁", + aliases: ["checkered_flag"], + tags: ["milestone", "finish"], + category: "Flags", + description: "chequered flag", + unicode_version: "6.0", + }, + { + emoji: "🚩", + aliases: ["triangular_flag_on_post"], + tags: [], + category: "Flags", + description: "triangular flag", + unicode_version: "6.0", + }, + { + emoji: "🎌", + aliases: ["crossed_flags"], + tags: [], + category: "Flags", + description: "crossed flags", + unicode_version: "6.0", + }, + { + emoji: "🏴", + aliases: ["black_flag"], + tags: [], + category: "Flags", + description: "black flag", + unicode_version: "7.0", + }, + { + emoji: "🏳️", + aliases: ["white_flag"], + tags: [], + category: "Flags", + description: "white flag", + unicode_version: "7.0", + }, + { + emoji: "🏳️‍🌈", + aliases: ["rainbow_flag"], + tags: ["pride"], + category: "Flags", + description: "rainbow flag", + unicode_version: "6.0", + }, + { + emoji: "🏳️‍⚧️", + aliases: ["transgender_flag"], + tags: [], + category: "Flags", + description: "transgender flag", + unicode_version: "13.0", + }, + { + emoji: "🏴‍☠️", + aliases: ["pirate_flag"], + tags: [], + category: "Flags", + description: "pirate flag", + unicode_version: "11.0", + }, + { + emoji: "🇦🇨", + aliases: ["ascension_island"], + tags: [], + category: "Flags", + description: "flag: Ascension Island", + unicode_version: "11.0", + }, + { + emoji: "🇦🇩", + aliases: ["andorra"], + tags: [], + category: "Flags", + description: "flag: Andorra", + unicode_version: "6.0", + }, + { + emoji: "🇦🇪", + aliases: ["united_arab_emirates"], + tags: [], + category: "Flags", + description: "flag: United Arab Emirates", + unicode_version: "6.0", + }, + { + emoji: "🇦🇫", + aliases: ["afghanistan"], + tags: [], + category: "Flags", + description: "flag: Afghanistan", + unicode_version: "6.0", + }, + { + emoji: "🇦🇬", + aliases: ["antigua_barbuda"], + tags: [], + category: "Flags", + description: "flag: Antigua & Barbuda", + unicode_version: "6.0", + }, + { + emoji: "🇦🇮", + aliases: ["anguilla"], + tags: [], + category: "Flags", + description: "flag: Anguilla", + unicode_version: "6.0", + }, + { + emoji: "🇦🇱", + aliases: ["albania"], + tags: [], + category: "Flags", + description: "flag: Albania", + unicode_version: "6.0", + }, + { + emoji: "🇦🇲", + aliases: ["armenia"], + tags: [], + category: "Flags", + description: "flag: Armenia", + unicode_version: "6.0", + }, + { + emoji: "🇦🇴", + aliases: ["angola"], + tags: [], + category: "Flags", + description: "flag: Angola", + unicode_version: "6.0", + }, + { + emoji: "🇦🇶", + aliases: ["antarctica"], + tags: [], + category: "Flags", + description: "flag: Antarctica", + unicode_version: "6.0", + }, + { + emoji: "🇦🇷", + aliases: ["argentina"], + tags: [], + category: "Flags", + description: "flag: Argentina", + unicode_version: "6.0", + }, + { + emoji: "🇦🇸", + aliases: ["american_samoa"], + tags: [], + category: "Flags", + description: "flag: American Samoa", + unicode_version: "6.0", + }, + { + emoji: "🇦🇹", + aliases: ["austria"], + tags: [], + category: "Flags", + description: "flag: Austria", + unicode_version: "6.0", + }, + { + emoji: "🇦🇺", + aliases: ["australia"], + tags: [], + category: "Flags", + description: "flag: Australia", + unicode_version: "6.0", + }, + { + emoji: "🇦🇼", + aliases: ["aruba"], + tags: [], + category: "Flags", + description: "flag: Aruba", + unicode_version: "6.0", + }, + { + emoji: "🇦🇽", + aliases: ["aland_islands"], + tags: [], + category: "Flags", + description: "flag: Åland Islands", + unicode_version: "6.0", + }, + { + emoji: "🇦🇿", + aliases: ["azerbaijan"], + tags: [], + category: "Flags", + description: "flag: Azerbaijan", + unicode_version: "6.0", + }, + { + emoji: "🇧🇦", + aliases: ["bosnia_herzegovina"], + tags: [], + category: "Flags", + description: "flag: Bosnia & Herzegovina", + unicode_version: "6.0", + }, + { + emoji: "🇧🇧", + aliases: ["barbados"], + tags: [], + category: "Flags", + description: "flag: Barbados", + unicode_version: "6.0", + }, + { + emoji: "🇧🇩", + aliases: ["bangladesh"], + tags: [], + category: "Flags", + description: "flag: Bangladesh", + unicode_version: "6.0", + }, + { + emoji: "🇧🇪", + aliases: ["belgium"], + tags: [], + category: "Flags", + description: "flag: Belgium", + unicode_version: "6.0", + }, + { + emoji: "🇧🇫", + aliases: ["burkina_faso"], + tags: [], + category: "Flags", + description: "flag: Burkina Faso", + unicode_version: "6.0", + }, + { + emoji: "🇧🇬", + aliases: ["bulgaria"], + tags: [], + category: "Flags", + description: "flag: Bulgaria", + unicode_version: "6.0", + }, + { + emoji: "🇧🇭", + aliases: ["bahrain"], + tags: [], + category: "Flags", + description: "flag: Bahrain", + unicode_version: "6.0", + }, + { + emoji: "🇧🇮", + aliases: ["burundi"], + tags: [], + category: "Flags", + description: "flag: Burundi", + unicode_version: "6.0", + }, + { + emoji: "🇧🇯", + aliases: ["benin"], + tags: [], + category: "Flags", + description: "flag: Benin", + unicode_version: "6.0", + }, + { + emoji: "🇧🇱", + aliases: ["st_barthelemy"], + tags: [], + category: "Flags", + description: "flag: St. Barthélemy", + unicode_version: "6.0", + }, + { + emoji: "🇧🇲", + aliases: ["bermuda"], + tags: [], + category: "Flags", + description: "flag: Bermuda", + unicode_version: "6.0", + }, + { + emoji: "🇧🇳", + aliases: ["brunei"], + tags: [], + category: "Flags", + description: "flag: Brunei", + unicode_version: "6.0", + }, + { + emoji: "🇧🇴", + aliases: ["bolivia"], + tags: [], + category: "Flags", + description: "flag: Bolivia", + unicode_version: "6.0", + }, + { + emoji: "🇧🇶", + aliases: ["caribbean_netherlands"], + tags: [], + category: "Flags", + description: "flag: Caribbean Netherlands", + unicode_version: "6.0", + }, + { + emoji: "🇧🇷", + aliases: ["brazil"], + tags: [], + category: "Flags", + description: "flag: Brazil", + unicode_version: "6.0", + }, + { + emoji: "🇧🇸", + aliases: ["bahamas"], + tags: [], + category: "Flags", + description: "flag: Bahamas", + unicode_version: "6.0", + }, + { + emoji: "🇧🇹", + aliases: ["bhutan"], + tags: [], + category: "Flags", + description: "flag: Bhutan", + unicode_version: "6.0", + }, + { + emoji: "🇧🇻", + aliases: ["bouvet_island"], + tags: [], + category: "Flags", + description: "flag: Bouvet Island", + unicode_version: "11.0", + }, + { + emoji: "🇧🇼", + aliases: ["botswana"], + tags: [], + category: "Flags", + description: "flag: Botswana", + unicode_version: "6.0", + }, + { + emoji: "🇧🇾", + aliases: ["belarus"], + tags: [], + category: "Flags", + description: "flag: Belarus", + unicode_version: "6.0", + }, + { + emoji: "🇧🇿", + aliases: ["belize"], + tags: [], + category: "Flags", + description: "flag: Belize", + unicode_version: "6.0", + }, + { + emoji: "🇨🇦", + aliases: ["canada"], + tags: [], + category: "Flags", + description: "flag: Canada", + unicode_version: "6.0", + }, + { + emoji: "🇨🇨", + aliases: ["cocos_islands"], + tags: ["keeling"], + category: "Flags", + description: "flag: Cocos (Keeling) Islands", + unicode_version: "6.0", + }, + { + emoji: "🇨🇩", + aliases: ["congo_kinshasa"], + tags: [], + category: "Flags", + description: "flag: Congo - Kinshasa", + unicode_version: "6.0", + }, + { + emoji: "🇨🇫", + aliases: ["central_african_republic"], + tags: [], + category: "Flags", + description: "flag: Central African Republic", + unicode_version: "6.0", + }, + { + emoji: "🇨🇬", + aliases: ["congo_brazzaville"], + tags: [], + category: "Flags", + description: "flag: Congo - Brazzaville", + unicode_version: "6.0", + }, + { + emoji: "🇨🇭", + aliases: ["switzerland"], + tags: [], + category: "Flags", + description: "flag: Switzerland", + unicode_version: "6.0", + }, + { + emoji: "🇨🇮", + aliases: ["cote_divoire"], + tags: ["ivory"], + category: "Flags", + description: "flag: Côte d’Ivoire", + unicode_version: "6.0", + }, + { + emoji: "🇨🇰", + aliases: ["cook_islands"], + tags: [], + category: "Flags", + description: "flag: Cook Islands", + unicode_version: "6.0", + }, + { + emoji: "🇨🇱", + aliases: ["chile"], + tags: [], + category: "Flags", + description: "flag: Chile", + unicode_version: "6.0", + }, + { + emoji: "🇨🇲", + aliases: ["cameroon"], + tags: [], + category: "Flags", + description: "flag: Cameroon", + unicode_version: "6.0", + }, + { + emoji: "🇨🇳", + aliases: ["cn"], + tags: ["china"], + category: "Flags", + description: "flag: China", + unicode_version: "6.0", + }, + { + emoji: "🇨🇴", + aliases: ["colombia"], + tags: [], + category: "Flags", + description: "flag: Colombia", + unicode_version: "6.0", + }, + { + emoji: "🇨🇵", + aliases: ["clipperton_island"], + tags: [], + category: "Flags", + description: "flag: Clipperton Island", + unicode_version: "11.0", + }, + { + emoji: "🇨🇷", + aliases: ["costa_rica"], + tags: [], + category: "Flags", + description: "flag: Costa Rica", + unicode_version: "6.0", + }, + { + emoji: "🇨🇺", + aliases: ["cuba"], + tags: [], + category: "Flags", + description: "flag: Cuba", + unicode_version: "6.0", + }, + { + emoji: "🇨🇻", + aliases: ["cape_verde"], + tags: [], + category: "Flags", + description: "flag: Cape Verde", + unicode_version: "6.0", + }, + { + emoji: "🇨🇼", + aliases: ["curacao"], + tags: [], + category: "Flags", + description: "flag: Curaçao", + unicode_version: "6.0", + }, + { + emoji: "🇨🇽", + aliases: ["christmas_island"], + tags: [], + category: "Flags", + description: "flag: Christmas Island", + unicode_version: "6.0", + }, + { + emoji: "🇨🇾", + aliases: ["cyprus"], + tags: [], + category: "Flags", + description: "flag: Cyprus", + unicode_version: "6.0", + }, + { + emoji: "🇨🇿", + aliases: ["czech_republic"], + tags: [], + category: "Flags", + description: "flag: Czechia", + unicode_version: "6.0", + }, + { + emoji: "🇩🇪", + aliases: ["de"], + tags: ["flag", "germany"], + category: "Flags", + description: "flag: Germany", + unicode_version: "6.0", + }, + { + emoji: "🇩🇬", + aliases: ["diego_garcia"], + tags: [], + category: "Flags", + description: "flag: Diego Garcia", + unicode_version: "11.0", + }, + { + emoji: "🇩🇯", + aliases: ["djibouti"], + tags: [], + category: "Flags", + description: "flag: Djibouti", + unicode_version: "6.0", + }, + { + emoji: "🇩🇰", + aliases: ["denmark"], + tags: [], + category: "Flags", + description: "flag: Denmark", + unicode_version: "6.0", + }, + { + emoji: "🇩🇲", + aliases: ["dominica"], + tags: [], + category: "Flags", + description: "flag: Dominica", + unicode_version: "6.0", + }, + { + emoji: "🇩🇴", + aliases: ["dominican_republic"], + tags: [], + category: "Flags", + description: "flag: Dominican Republic", + unicode_version: "6.0", + }, + { + emoji: "🇩🇿", + aliases: ["algeria"], + tags: [], + category: "Flags", + description: "flag: Algeria", + unicode_version: "6.0", + }, + { + emoji: "🇪🇦", + aliases: ["ceuta_melilla"], + tags: [], + category: "Flags", + description: "flag: Ceuta & Melilla", + unicode_version: "11.0", + }, + { + emoji: "🇪🇨", + aliases: ["ecuador"], + tags: [], + category: "Flags", + description: "flag: Ecuador", + unicode_version: "6.0", + }, + { + emoji: "🇪🇪", + aliases: ["estonia"], + tags: [], + category: "Flags", + description: "flag: Estonia", + unicode_version: "6.0", + }, + { + emoji: "🇪🇬", + aliases: ["egypt"], + tags: [], + category: "Flags", + description: "flag: Egypt", + unicode_version: "6.0", + }, + { + emoji: "🇪🇭", + aliases: ["western_sahara"], + tags: [], + category: "Flags", + description: "flag: Western Sahara", + unicode_version: "6.0", + }, + { + emoji: "🇪🇷", + aliases: ["eritrea"], + tags: [], + category: "Flags", + description: "flag: Eritrea", + unicode_version: "6.0", + }, + { + emoji: "🇪🇸", + aliases: ["es"], + tags: ["spain"], + category: "Flags", + description: "flag: Spain", + unicode_version: "6.0", + }, + { + emoji: "🇪🇹", + aliases: ["ethiopia"], + tags: [], + category: "Flags", + description: "flag: Ethiopia", + unicode_version: "6.0", + }, + { + emoji: "🇪🇺", + aliases: ["eu", "european_union"], + tags: [], + category: "Flags", + description: "flag: European Union", + unicode_version: "6.0", + }, + { + emoji: "🇫🇮", + aliases: ["finland"], + tags: [], + category: "Flags", + description: "flag: Finland", + unicode_version: "6.0", + }, + { + emoji: "🇫🇯", + aliases: ["fiji"], + tags: [], + category: "Flags", + description: "flag: Fiji", + unicode_version: "6.0", + }, + { + emoji: "🇫🇰", + aliases: ["falkland_islands"], + tags: [], + category: "Flags", + description: "flag: Falkland Islands", + unicode_version: "6.0", + }, + { + emoji: "🇫🇲", + aliases: ["micronesia"], + tags: [], + category: "Flags", + description: "flag: Micronesia", + unicode_version: "6.0", + }, + { + emoji: "🇫🇴", + aliases: ["faroe_islands"], + tags: [], + category: "Flags", + description: "flag: Faroe Islands", + unicode_version: "6.0", + }, + { + emoji: "🇫🇷", + aliases: ["fr"], + tags: ["france", "french"], + category: "Flags", + description: "flag: France", + unicode_version: "6.0", + }, + { + emoji: "🇬🇦", + aliases: ["gabon"], + tags: [], + category: "Flags", + description: "flag: Gabon", + unicode_version: "6.0", + }, + { + emoji: "🇬🇧", + aliases: ["gb", "uk"], + tags: ["flag", "british"], + category: "Flags", + description: "flag: United Kingdom", + unicode_version: "6.0", + }, + { + emoji: "🇬🇩", + aliases: ["grenada"], + tags: [], + category: "Flags", + description: "flag: Grenada", + unicode_version: "6.0", + }, + { + emoji: "🇬🇪", + aliases: ["georgia"], + tags: [], + category: "Flags", + description: "flag: Georgia", + unicode_version: "6.0", + }, + { + emoji: "🇬🇫", + aliases: ["french_guiana"], + tags: [], + category: "Flags", + description: "flag: French Guiana", + unicode_version: "6.0", + }, + { + emoji: "🇬🇬", + aliases: ["guernsey"], + tags: [], + category: "Flags", + description: "flag: Guernsey", + unicode_version: "6.0", + }, + { + emoji: "🇬🇭", + aliases: ["ghana"], + tags: [], + category: "Flags", + description: "flag: Ghana", + unicode_version: "6.0", + }, + { + emoji: "🇬🇮", + aliases: ["gibraltar"], + tags: [], + category: "Flags", + description: "flag: Gibraltar", + unicode_version: "6.0", + }, + { + emoji: "🇬🇱", + aliases: ["greenland"], + tags: [], + category: "Flags", + description: "flag: Greenland", + unicode_version: "6.0", + }, + { + emoji: "🇬🇲", + aliases: ["gambia"], + tags: [], + category: "Flags", + description: "flag: Gambia", + unicode_version: "6.0", + }, + { + emoji: "🇬🇳", + aliases: ["guinea"], + tags: [], + category: "Flags", + description: "flag: Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇬🇵", + aliases: ["guadeloupe"], + tags: [], + category: "Flags", + description: "flag: Guadeloupe", + unicode_version: "6.0", + }, + { + emoji: "🇬🇶", + aliases: ["equatorial_guinea"], + tags: [], + category: "Flags", + description: "flag: Equatorial Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇬🇷", + aliases: ["greece"], + tags: [], + category: "Flags", + description: "flag: Greece", + unicode_version: "6.0", + }, + { + emoji: "🇬🇸", + aliases: ["south_georgia_south_sandwich_islands"], + tags: [], + category: "Flags", + description: "flag: South Georgia & South Sandwich Islands", + unicode_version: "6.0", + }, + { + emoji: "🇬🇹", + aliases: ["guatemala"], + tags: [], + category: "Flags", + description: "flag: Guatemala", + unicode_version: "6.0", + }, + { + emoji: "🇬🇺", + aliases: ["guam"], + tags: [], + category: "Flags", + description: "flag: Guam", + unicode_version: "6.0", + }, + { + emoji: "🇬🇼", + aliases: ["guinea_bissau"], + tags: [], + category: "Flags", + description: "flag: Guinea-Bissau", + unicode_version: "6.0", + }, + { + emoji: "🇬🇾", + aliases: ["guyana"], + tags: [], + category: "Flags", + description: "flag: Guyana", + unicode_version: "6.0", + }, + { + emoji: "🇭🇰", + aliases: ["hong_kong"], + tags: [], + category: "Flags", + description: "flag: Hong Kong SAR China", + unicode_version: "6.0", + }, + { + emoji: "🇭🇲", + aliases: ["heard_mcdonald_islands"], + tags: [], + category: "Flags", + description: "flag: Heard & McDonald Islands", + unicode_version: "11.0", + }, + { + emoji: "🇭🇳", + aliases: ["honduras"], + tags: [], + category: "Flags", + description: "flag: Honduras", + unicode_version: "6.0", + }, + { + emoji: "🇭🇷", + aliases: ["croatia"], + tags: [], + category: "Flags", + description: "flag: Croatia", + unicode_version: "6.0", + }, + { + emoji: "🇭🇹", + aliases: ["haiti"], + tags: [], + category: "Flags", + description: "flag: Haiti", + unicode_version: "6.0", + }, + { + emoji: "🇭🇺", + aliases: ["hungary"], + tags: [], + category: "Flags", + description: "flag: Hungary", + unicode_version: "6.0", + }, + { + emoji: "🇮🇨", + aliases: ["canary_islands"], + tags: [], + category: "Flags", + description: "flag: Canary Islands", + unicode_version: "6.0", + }, + { + emoji: "🇮🇩", + aliases: ["indonesia"], + tags: [], + category: "Flags", + description: "flag: Indonesia", + unicode_version: "6.0", + }, + { + emoji: "🇮🇪", + aliases: ["ireland"], + tags: [], + category: "Flags", + description: "flag: Ireland", + unicode_version: "6.0", + }, + { + emoji: "🇮🇱", + aliases: ["israel"], + tags: [], + category: "Flags", + description: "flag: Israel", + unicode_version: "6.0", + }, + { + emoji: "🇮🇲", + aliases: ["isle_of_man"], + tags: [], + category: "Flags", + description: "flag: Isle of Man", + unicode_version: "6.0", + }, + { + emoji: "🇮🇳", + aliases: ["india"], + tags: [], + category: "Flags", + description: "flag: India", + unicode_version: "6.0", + }, + { + emoji: "🇮🇴", + aliases: ["british_indian_ocean_territory"], + tags: [], + category: "Flags", + description: "flag: British Indian Ocean Territory", + unicode_version: "6.0", + }, + { + emoji: "🇮🇶", + aliases: ["iraq"], + tags: [], + category: "Flags", + description: "flag: Iraq", + unicode_version: "6.0", + }, + { + emoji: "🇮🇷", + aliases: ["iran"], + tags: [], + category: "Flags", + description: "flag: Iran", + unicode_version: "6.0", + }, + { + emoji: "🇮🇸", + aliases: ["iceland"], + tags: [], + category: "Flags", + description: "flag: Iceland", + unicode_version: "6.0", + }, + { + emoji: "🇮🇹", + aliases: ["it"], + tags: ["italy"], + category: "Flags", + description: "flag: Italy", + unicode_version: "6.0", + }, + { + emoji: "🇯🇪", + aliases: ["jersey"], + tags: [], + category: "Flags", + description: "flag: Jersey", + unicode_version: "6.0", + }, + { + emoji: "🇯🇲", + aliases: ["jamaica"], + tags: [], + category: "Flags", + description: "flag: Jamaica", + unicode_version: "6.0", + }, + { + emoji: "🇯🇴", + aliases: ["jordan"], + tags: [], + category: "Flags", + description: "flag: Jordan", + unicode_version: "6.0", + }, + { + emoji: "🇯🇵", + aliases: ["jp"], + tags: ["japan"], + category: "Flags", + description: "flag: Japan", + unicode_version: "6.0", + }, + { + emoji: "🇰🇪", + aliases: ["kenya"], + tags: [], + category: "Flags", + description: "flag: Kenya", + unicode_version: "6.0", + }, + { + emoji: "🇰🇬", + aliases: ["kyrgyzstan"], + tags: [], + category: "Flags", + description: "flag: Kyrgyzstan", + unicode_version: "6.0", + }, + { + emoji: "🇰🇭", + aliases: ["cambodia"], + tags: [], + category: "Flags", + description: "flag: Cambodia", + unicode_version: "6.0", + }, + { + emoji: "🇰🇮", + aliases: ["kiribati"], + tags: [], + category: "Flags", + description: "flag: Kiribati", + unicode_version: "6.0", + }, + { + emoji: "🇰🇲", + aliases: ["comoros"], + tags: [], + category: "Flags", + description: "flag: Comoros", + unicode_version: "6.0", + }, + { + emoji: "🇰🇳", + aliases: ["st_kitts_nevis"], + tags: [], + category: "Flags", + description: "flag: St. Kitts & Nevis", + unicode_version: "6.0", + }, + { + emoji: "🇰🇵", + aliases: ["north_korea"], + tags: [], + category: "Flags", + description: "flag: North Korea", + unicode_version: "6.0", + }, + { + emoji: "🇰🇷", + aliases: ["kr"], + tags: ["korea"], + category: "Flags", + description: "flag: South Korea", + unicode_version: "6.0", + }, + { + emoji: "🇰🇼", + aliases: ["kuwait"], + tags: [], + category: "Flags", + description: "flag: Kuwait", + unicode_version: "6.0", + }, + { + emoji: "🇰🇾", + aliases: ["cayman_islands"], + tags: [], + category: "Flags", + description: "flag: Cayman Islands", + unicode_version: "6.0", + }, + { + emoji: "🇰🇿", + aliases: ["kazakhstan"], + tags: [], + category: "Flags", + description: "flag: Kazakhstan", + unicode_version: "6.0", + }, + { + emoji: "🇱🇦", + aliases: ["laos"], + tags: [], + category: "Flags", + description: "flag: Laos", + unicode_version: "6.0", + }, + { + emoji: "🇱🇧", + aliases: ["lebanon"], + tags: [], + category: "Flags", + description: "flag: Lebanon", + unicode_version: "6.0", + }, + { + emoji: "🇱🇨", + aliases: ["st_lucia"], + tags: [], + category: "Flags", + description: "flag: St. Lucia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇮", + aliases: ["liechtenstein"], + tags: [], + category: "Flags", + description: "flag: Liechtenstein", + unicode_version: "6.0", + }, + { + emoji: "🇱🇰", + aliases: ["sri_lanka"], + tags: [], + category: "Flags", + description: "flag: Sri Lanka", + unicode_version: "6.0", + }, + { + emoji: "🇱🇷", + aliases: ["liberia"], + tags: [], + category: "Flags", + description: "flag: Liberia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇸", + aliases: ["lesotho"], + tags: [], + category: "Flags", + description: "flag: Lesotho", + unicode_version: "6.0", + }, + { + emoji: "🇱🇹", + aliases: ["lithuania"], + tags: [], + category: "Flags", + description: "flag: Lithuania", + unicode_version: "6.0", + }, + { + emoji: "🇱🇺", + aliases: ["luxembourg"], + tags: [], + category: "Flags", + description: "flag: Luxembourg", + unicode_version: "6.0", + }, + { + emoji: "🇱🇻", + aliases: ["latvia"], + tags: [], + category: "Flags", + description: "flag: Latvia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇾", + aliases: ["libya"], + tags: [], + category: "Flags", + description: "flag: Libya", + unicode_version: "6.0", + }, + { + emoji: "🇲🇦", + aliases: ["morocco"], + tags: [], + category: "Flags", + description: "flag: Morocco", + unicode_version: "6.0", + }, + { + emoji: "🇲🇨", + aliases: ["monaco"], + tags: [], + category: "Flags", + description: "flag: Monaco", + unicode_version: "6.0", + }, + { + emoji: "🇲🇩", + aliases: ["moldova"], + tags: [], + category: "Flags", + description: "flag: Moldova", + unicode_version: "6.0", + }, + { + emoji: "🇲🇪", + aliases: ["montenegro"], + tags: [], + category: "Flags", + description: "flag: Montenegro", + unicode_version: "6.0", + }, + { + emoji: "🇲🇫", + aliases: ["st_martin"], + tags: [], + category: "Flags", + description: "flag: St. Martin", + unicode_version: "11.0", + }, + { + emoji: "🇲🇬", + aliases: ["madagascar"], + tags: [], + category: "Flags", + description: "flag: Madagascar", + unicode_version: "6.0", + }, + { + emoji: "🇲🇭", + aliases: ["marshall_islands"], + tags: [], + category: "Flags", + description: "flag: Marshall Islands", + unicode_version: "6.0", + }, + { + emoji: "🇲🇰", + aliases: ["macedonia"], + tags: [], + category: "Flags", + description: "flag: North Macedonia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇱", + aliases: ["mali"], + tags: [], + category: "Flags", + description: "flag: Mali", + unicode_version: "6.0", + }, + { + emoji: "🇲🇲", + aliases: ["myanmar"], + tags: ["burma"], + category: "Flags", + description: "flag: Myanmar (Burma)", + unicode_version: "6.0", + }, + { + emoji: "🇲🇳", + aliases: ["mongolia"], + tags: [], + category: "Flags", + description: "flag: Mongolia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇴", + aliases: ["macau"], + tags: [], + category: "Flags", + description: "flag: Macao SAR China", + unicode_version: "6.0", + }, + { + emoji: "🇲🇵", + aliases: ["northern_mariana_islands"], + tags: [], + category: "Flags", + description: "flag: Northern Mariana Islands", + unicode_version: "6.0", + }, + { + emoji: "🇲🇶", + aliases: ["martinique"], + tags: [], + category: "Flags", + description: "flag: Martinique", + unicode_version: "6.0", + }, + { + emoji: "🇲🇷", + aliases: ["mauritania"], + tags: [], + category: "Flags", + description: "flag: Mauritania", + unicode_version: "6.0", + }, + { + emoji: "🇲🇸", + aliases: ["montserrat"], + tags: [], + category: "Flags", + description: "flag: Montserrat", + unicode_version: "6.0", + }, + { + emoji: "🇲🇹", + aliases: ["malta"], + tags: [], + category: "Flags", + description: "flag: Malta", + unicode_version: "6.0", + }, + { + emoji: "🇲🇺", + aliases: ["mauritius"], + tags: [], + category: "Flags", + description: "flag: Mauritius", + unicode_version: "6.0", + }, + { + emoji: "🇲🇻", + aliases: ["maldives"], + tags: [], + category: "Flags", + description: "flag: Maldives", + unicode_version: "6.0", + }, + { + emoji: "🇲🇼", + aliases: ["malawi"], + tags: [], + category: "Flags", + description: "flag: Malawi", + unicode_version: "6.0", + }, + { + emoji: "🇲🇽", + aliases: ["mexico"], + tags: [], + category: "Flags", + description: "flag: Mexico", + unicode_version: "6.0", + }, + { + emoji: "🇲🇾", + aliases: ["malaysia"], + tags: [], + category: "Flags", + description: "flag: Malaysia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇿", + aliases: ["mozambique"], + tags: [], + category: "Flags", + description: "flag: Mozambique", + unicode_version: "6.0", + }, + { + emoji: "🇳🇦", + aliases: ["namibia"], + tags: [], + category: "Flags", + description: "flag: Namibia", + unicode_version: "6.0", + }, + { + emoji: "🇳🇨", + aliases: ["new_caledonia"], + tags: [], + category: "Flags", + description: "flag: New Caledonia", + unicode_version: "6.0", + }, + { + emoji: "🇳🇪", + aliases: ["niger"], + tags: [], + category: "Flags", + description: "flag: Niger", + unicode_version: "6.0", + }, + { + emoji: "🇳🇫", + aliases: ["norfolk_island"], + tags: [], + category: "Flags", + description: "flag: Norfolk Island", + unicode_version: "6.0", + }, + { + emoji: "🇳🇬", + aliases: ["nigeria"], + tags: [], + category: "Flags", + description: "flag: Nigeria", + unicode_version: "6.0", + }, + { + emoji: "🇳🇮", + aliases: ["nicaragua"], + tags: [], + category: "Flags", + description: "flag: Nicaragua", + unicode_version: "6.0", + }, + { + emoji: "🇳🇱", + aliases: ["netherlands"], + tags: [], + category: "Flags", + description: "flag: Netherlands", + unicode_version: "6.0", + }, + { + emoji: "🇳🇴", + aliases: ["norway"], + tags: [], + category: "Flags", + description: "flag: Norway", + unicode_version: "6.0", + }, + { + emoji: "🇳🇵", + aliases: ["nepal"], + tags: [], + category: "Flags", + description: "flag: Nepal", + unicode_version: "6.0", + }, + { + emoji: "🇳🇷", + aliases: ["nauru"], + tags: [], + category: "Flags", + description: "flag: Nauru", + unicode_version: "6.0", + }, + { + emoji: "🇳🇺", + aliases: ["niue"], + tags: [], + category: "Flags", + description: "flag: Niue", + unicode_version: "6.0", + }, + { + emoji: "🇳🇿", + aliases: ["new_zealand"], + tags: [], + category: "Flags", + description: "flag: New Zealand", + unicode_version: "6.0", + }, + { + emoji: "🇴🇲", + aliases: ["oman"], + tags: [], + category: "Flags", + description: "flag: Oman", + unicode_version: "6.0", + }, + { + emoji: "🇵🇦", + aliases: ["panama"], + tags: [], + category: "Flags", + description: "flag: Panama", + unicode_version: "6.0", + }, + { + emoji: "🇵🇪", + aliases: ["peru"], + tags: [], + category: "Flags", + description: "flag: Peru", + unicode_version: "6.0", + }, + { + emoji: "🇵🇫", + aliases: ["french_polynesia"], + tags: [], + category: "Flags", + description: "flag: French Polynesia", + unicode_version: "6.0", + }, + { + emoji: "🇵🇬", + aliases: ["papua_new_guinea"], + tags: [], + category: "Flags", + description: "flag: Papua New Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇵🇭", + aliases: ["philippines"], + tags: [], + category: "Flags", + description: "flag: Philippines", + unicode_version: "6.0", + }, + { + emoji: "🇵🇰", + aliases: ["pakistan"], + tags: [], + category: "Flags", + description: "flag: Pakistan", + unicode_version: "6.0", + }, + { + emoji: "🇵🇱", + aliases: ["poland"], + tags: [], + category: "Flags", + description: "flag: Poland", + unicode_version: "6.0", + }, + { + emoji: "🇵🇲", + aliases: ["st_pierre_miquelon"], + tags: [], + category: "Flags", + description: "flag: St. Pierre & Miquelon", + unicode_version: "6.0", + }, + { + emoji: "🇵🇳", + aliases: ["pitcairn_islands"], + tags: [], + category: "Flags", + description: "flag: Pitcairn Islands", + unicode_version: "6.0", + }, + { + emoji: "🇵🇷", + aliases: ["puerto_rico"], + tags: [], + category: "Flags", + description: "flag: Puerto Rico", + unicode_version: "6.0", + }, + { + emoji: "🇵🇸", + aliases: ["palestinian_territories"], + tags: [], + category: "Flags", + description: "flag: Palestinian Territories", + unicode_version: "6.0", + }, + { + emoji: "🇵🇹", + aliases: ["portugal"], + tags: [], + category: "Flags", + description: "flag: Portugal", + unicode_version: "6.0", + }, + { + emoji: "🇵🇼", + aliases: ["palau"], + tags: [], + category: "Flags", + description: "flag: Palau", + unicode_version: "6.0", + }, + { + emoji: "🇵🇾", + aliases: ["paraguay"], + tags: [], + category: "Flags", + description: "flag: Paraguay", + unicode_version: "6.0", + }, + { + emoji: "🇶🇦", + aliases: ["qatar"], + tags: [], + category: "Flags", + description: "flag: Qatar", + unicode_version: "6.0", + }, + { + emoji: "🇷🇪", + aliases: ["reunion"], + tags: [], + category: "Flags", + description: "flag: Réunion", + unicode_version: "6.0", + }, + { + emoji: "🇷🇴", + aliases: ["romania"], + tags: [], + category: "Flags", + description: "flag: Romania", + unicode_version: "6.0", + }, + { + emoji: "🇷🇸", + aliases: ["serbia"], + tags: [], + category: "Flags", + description: "flag: Serbia", + unicode_version: "6.0", + }, + { + emoji: "🇷🇺", + aliases: ["ru"], + tags: ["russia"], + category: "Flags", + description: "flag: Russia", + unicode_version: "6.0", + }, + { + emoji: "🇷🇼", + aliases: ["rwanda"], + tags: [], + category: "Flags", + description: "flag: Rwanda", + unicode_version: "6.0", + }, + { + emoji: "🇸🇦", + aliases: ["saudi_arabia"], + tags: [], + category: "Flags", + description: "flag: Saudi Arabia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇧", + aliases: ["solomon_islands"], + tags: [], + category: "Flags", + description: "flag: Solomon Islands", + unicode_version: "6.0", + }, + { + emoji: "🇸🇨", + aliases: ["seychelles"], + tags: [], + category: "Flags", + description: "flag: Seychelles", + unicode_version: "6.0", + }, + { + emoji: "🇸🇩", + aliases: ["sudan"], + tags: [], + category: "Flags", + description: "flag: Sudan", + unicode_version: "6.0", + }, + { + emoji: "🇸🇪", + aliases: ["sweden"], + tags: [], + category: "Flags", + description: "flag: Sweden", + unicode_version: "6.0", + }, + { + emoji: "🇸🇬", + aliases: ["singapore"], + tags: [], + category: "Flags", + description: "flag: Singapore", + unicode_version: "6.0", + }, + { + emoji: "🇸🇭", + aliases: ["st_helena"], + tags: [], + category: "Flags", + description: "flag: St. Helena", + unicode_version: "6.0", + }, + { + emoji: "🇸🇮", + aliases: ["slovenia"], + tags: [], + category: "Flags", + description: "flag: Slovenia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇯", + aliases: ["svalbard_jan_mayen"], + tags: [], + category: "Flags", + description: "flag: Svalbard & Jan Mayen", + unicode_version: "11.0", + }, + { + emoji: "🇸🇰", + aliases: ["slovakia"], + tags: [], + category: "Flags", + description: "flag: Slovakia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇱", + aliases: ["sierra_leone"], + tags: [], + category: "Flags", + description: "flag: Sierra Leone", + unicode_version: "6.0", + }, + { + emoji: "🇸🇲", + aliases: ["san_marino"], + tags: [], + category: "Flags", + description: "flag: San Marino", + unicode_version: "6.0", + }, + { + emoji: "🇸🇳", + aliases: ["senegal"], + tags: [], + category: "Flags", + description: "flag: Senegal", + unicode_version: "6.0", + }, + { + emoji: "🇸🇴", + aliases: ["somalia"], + tags: [], + category: "Flags", + description: "flag: Somalia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇷", + aliases: ["suriname"], + tags: [], + category: "Flags", + description: "flag: Suriname", + unicode_version: "6.0", + }, + { + emoji: "🇸🇸", + aliases: ["south_sudan"], + tags: [], + category: "Flags", + description: "flag: South Sudan", + unicode_version: "6.0", + }, + { + emoji: "🇸🇹", + aliases: ["sao_tome_principe"], + tags: [], + category: "Flags", + description: "flag: São Tomé & Príncipe", + unicode_version: "6.0", + }, + { + emoji: "🇸🇻", + aliases: ["el_salvador"], + tags: [], + category: "Flags", + description: "flag: El Salvador", + unicode_version: "6.0", + }, + { + emoji: "🇸🇽", + aliases: ["sint_maarten"], + tags: [], + category: "Flags", + description: "flag: Sint Maarten", + unicode_version: "6.0", + }, + { + emoji: "🇸🇾", + aliases: ["syria"], + tags: [], + category: "Flags", + description: "flag: Syria", + unicode_version: "6.0", + }, + { + emoji: "🇸🇿", + aliases: ["swaziland"], + tags: [], + category: "Flags", + description: "flag: Eswatini", + unicode_version: "6.0", + }, + { + emoji: "🇹🇦", + aliases: ["tristan_da_cunha"], + tags: [], + category: "Flags", + description: "flag: Tristan da Cunha", + unicode_version: "11.0", + }, + { + emoji: "🇹🇨", + aliases: ["turks_caicos_islands"], + tags: [], + category: "Flags", + description: "flag: Turks & Caicos Islands", + unicode_version: "6.0", + }, + { + emoji: "🇹🇩", + aliases: ["chad"], + tags: [], + category: "Flags", + description: "flag: Chad", + unicode_version: "6.0", + }, + { + emoji: "🇹🇫", + aliases: ["french_southern_territories"], + tags: [], + category: "Flags", + description: "flag: French Southern Territories", + unicode_version: "6.0", + }, + { + emoji: "🇹🇬", + aliases: ["togo"], + tags: [], + category: "Flags", + description: "flag: Togo", + unicode_version: "6.0", + }, + { + emoji: "🇹🇭", + aliases: ["thailand"], + tags: [], + category: "Flags", + description: "flag: Thailand", + unicode_version: "6.0", + }, + { + emoji: "🇹🇯", + aliases: ["tajikistan"], + tags: [], + category: "Flags", + description: "flag: Tajikistan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇰", + aliases: ["tokelau"], + tags: [], + category: "Flags", + description: "flag: Tokelau", + unicode_version: "6.0", + }, + { + emoji: "🇹🇱", + aliases: ["timor_leste"], + tags: [], + category: "Flags", + description: "flag: Timor-Leste", + unicode_version: "6.0", + }, + { + emoji: "🇹🇲", + aliases: ["turkmenistan"], + tags: [], + category: "Flags", + description: "flag: Turkmenistan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇳", + aliases: ["tunisia"], + tags: [], + category: "Flags", + description: "flag: Tunisia", + unicode_version: "6.0", + }, + { + emoji: "🇹🇴", + aliases: ["tonga"], + tags: [], + category: "Flags", + description: "flag: Tonga", + unicode_version: "6.0", + }, + { + emoji: "🇹🇷", + aliases: ["tr"], + tags: ["turkey"], + category: "Flags", + description: "flag: Turkey", + unicode_version: "8.0", + }, + { + emoji: "🇹🇹", + aliases: ["trinidad_tobago"], + tags: [], + category: "Flags", + description: "flag: Trinidad & Tobago", + unicode_version: "6.0", + }, + { + emoji: "🇹🇻", + aliases: ["tuvalu"], + tags: [], + category: "Flags", + description: "flag: Tuvalu", + unicode_version: "6.0", + }, + { + emoji: "🇹🇼", + aliases: ["taiwan"], + tags: [], + category: "Flags", + description: "flag: Taiwan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇿", + aliases: ["tanzania"], + tags: [], + category: "Flags", + description: "flag: Tanzania", + unicode_version: "6.0", + }, + { + emoji: "🇺🇦", + aliases: ["ukraine"], + tags: [], + category: "Flags", + description: "flag: Ukraine", + unicode_version: "6.0", + }, + { + emoji: "🇺🇬", + aliases: ["uganda"], + tags: [], + category: "Flags", + description: "flag: Uganda", + unicode_version: "6.0", + }, + { + emoji: "🇺🇲", + aliases: ["us_outlying_islands"], + tags: [], + category: "Flags", + description: "flag: U.S. Outlying Islands", + unicode_version: "11.0", + }, + { + emoji: "🇺🇳", + aliases: ["united_nations"], + tags: [], + category: "Flags", + description: "flag: United Nations", + unicode_version: "11.0", + }, + { + emoji: "🇺🇸", + aliases: ["us"], + tags: ["flag", "united", "america"], + category: "Flags", + description: "flag: United States", + unicode_version: "6.0", + }, + { + emoji: "🇺🇾", + aliases: ["uruguay"], + tags: [], + category: "Flags", + description: "flag: Uruguay", + unicode_version: "6.0", + }, + { + emoji: "🇺🇿", + aliases: ["uzbekistan"], + tags: [], + category: "Flags", + description: "flag: Uzbekistan", + unicode_version: "6.0", + }, + { + emoji: "🇻🇦", + aliases: ["vatican_city"], + tags: [], + category: "Flags", + description: "flag: Vatican City", + unicode_version: "6.0", + }, + { + emoji: "🇻🇨", + aliases: ["st_vincent_grenadines"], + tags: [], + category: "Flags", + description: "flag: St. Vincent & Grenadines", + unicode_version: "6.0", + }, + { + emoji: "🇻🇪", + aliases: ["venezuela"], + tags: [], + category: "Flags", + description: "flag: Venezuela", + unicode_version: "6.0", + }, + { + emoji: "🇻🇬", + aliases: ["british_virgin_islands"], + tags: [], + category: "Flags", + description: "flag: British Virgin Islands", + unicode_version: "6.0", + }, + { + emoji: "🇻🇮", + aliases: ["us_virgin_islands"], + tags: [], + category: "Flags", + description: "flag: U.S. Virgin Islands", + unicode_version: "6.0", + }, + { + emoji: "🇻🇳", + aliases: ["vietnam"], + tags: [], + category: "Flags", + description: "flag: Vietnam", + unicode_version: "6.0", + }, + { + emoji: "🇻🇺", + aliases: ["vanuatu"], + tags: [], + category: "Flags", + description: "flag: Vanuatu", + unicode_version: "6.0", + }, + { + emoji: "🇼🇫", + aliases: ["wallis_futuna"], + tags: [], + category: "Flags", + description: "flag: Wallis & Futuna", + unicode_version: "6.0", + }, + { + emoji: "🇼🇸", + aliases: ["samoa"], + tags: [], + category: "Flags", + description: "flag: Samoa", + unicode_version: "6.0", + }, + { + emoji: "🇽🇰", + aliases: ["kosovo"], + tags: [], + category: "Flags", + description: "flag: Kosovo", + unicode_version: "6.0", + }, + { + emoji: "🇾🇪", + aliases: ["yemen"], + tags: [], + category: "Flags", + description: "flag: Yemen", + unicode_version: "6.0", + }, + { + emoji: "🇾🇹", + aliases: ["mayotte"], + tags: [], + category: "Flags", + description: "flag: Mayotte", + unicode_version: "6.0", + }, + { + emoji: "🇿🇦", + aliases: ["south_africa"], + tags: [], + category: "Flags", + description: "flag: South Africa", + unicode_version: "6.0", + }, + { + emoji: "🇿🇲", + aliases: ["zambia"], + tags: [], + category: "Flags", + description: "flag: Zambia", + unicode_version: "6.0", + }, + { + emoji: "🇿🇼", + aliases: ["zimbabwe"], + tags: [], + category: "Flags", + description: "flag: Zimbabwe", + unicode_version: "6.0", + }, + { + emoji: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + aliases: ["england"], + tags: [], + category: "Flags", + description: "flag: England", + unicode_version: "11.0", + }, + { + emoji: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + aliases: ["scotland"], + tags: [], + category: "Flags", + description: "flag: Scotland", + unicode_version: "11.0", + }, + { + emoji: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + aliases: ["wales"], + tags: [], + category: "Flags", + description: "flag: Wales", + unicode_version: "11.0", + }, +]; diff --git a/web/src/app/emojisMapped.js b/web/src/app/emojisMapped.js new file mode 100644 index 00000000..d823bbe0 --- /dev/null +++ b/web/src/app/emojisMapped.js @@ -0,0 +1,4 @@ +import { rawEmojis } from "./emojis"; + +// Format emojis (see emoji.js) +export default Object.fromEntries(rawEmojis.flatMap((emoji) => emoji.aliases.map((alias) => [alias, emoji.emoji]))); diff --git a/web/src/app/errors.js b/web/src/app/errors.js new file mode 100644 index 00000000..28f49af1 --- /dev/null +++ b/web/src/app/errors.js @@ -0,0 +1,80 @@ +/* eslint-disable max-classes-per-file */ +// This is a subset of, and the counterpart to errors.go + +const maybeToJson = async (response) => { + try { + return await response.json(); + } catch (e) { + return null; + } +}; + +export class UnauthorizedError extends Error { + constructor() { + super("Unauthorized"); + } +} + +export class UserExistsError extends Error { + static CODE = 40901; // errHTTPConflictUserExists + + constructor() { + super("Username already exists"); + } +} + +export class TopicReservedError extends Error { + static CODE = 40902; // errHTTPConflictTopicReserved + + constructor() { + super("Topic already reserved"); + } +} + +export class AccountCreateLimitReachedError extends Error { + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation + + constructor() { + super("Account creation limit reached"); + } +} + +export class IncorrectPasswordError extends Error { + static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation + + constructor() { + super("Password incorrect"); + } +} + +export const throwAppError = async (response) => { + if (response.status === 401 || response.status === 403) { + console.log(`[Error] HTTP ${response.status}`, response); + throw new UnauthorizedError(); + } + const error = await maybeToJson(response); + if (error?.code) { + console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); + if (error.code === UserExistsError.CODE) { + throw new UserExistsError(); + } else if (error.code === TopicReservedError.CODE) { + throw new TopicReservedError(); + } else if (error.code === AccountCreateLimitReachedError.CODE) { + throw new AccountCreateLimitReachedError(); + } else if (error.code === IncorrectPasswordError.CODE) { + throw new IncorrectPasswordError(); + } else if (error?.error) { + throw new Error(`Error ${error.code}: ${error.error}`); + } + } + console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); + throw new Error(`Unexpected response ${response.status}`); +}; + +export const fetchOrThrow = async (url, options) => { + const response = await fetch(url, options); + if (response.status !== 200) { + await throwAppError(response); + } + return response; // Promise! +}; diff --git a/web/src/components/i18n.js b/web/src/app/i18n.js similarity index 50% rename from web/src/components/i18n.js rename to web/src/app/i18n.js index 42eb5721..298f595c 100644 --- a/web/src/components/i18n.js +++ b/web/src/app/i18n.js @@ -1,7 +1,7 @@ -import i18n from 'i18next'; -import Backend from 'i18next-http-backend'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import { initReactI18next } from 'react-i18next'; +import i18next from "i18next"; +import Backend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; // Translations using i18next // - Options: https://www.i18next.com/overview/configuration-options @@ -11,19 +11,20 @@ import { initReactI18next } from 'react-i18next'; // See example project here: // https://github.com/i18next/react-i18next/tree/master/example/react -i18n +const initI18n = () => + i18next .use(Backend) .use(LanguageDetector) .use(initReactI18next) .init({ - fallbackLng: 'en', - debug: true, - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - }, - backend: { - loadPath: '/static/langs/{{lng}}.json', - } + fallbackLng: "en", + debug: true, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + backend: { + loadPath: "/static/langs/{{lng}}.json", + }, }); -export default i18n; +export default initI18n; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js new file mode 100644 index 00000000..0bd5136d --- /dev/null +++ b/web/src/app/notificationUtils.js @@ -0,0 +1,81 @@ +// This is a separate file since the other utils import `config.js`, which depends on `window` +// and cannot be used in the service worker + +import emojisMapped from "./emojisMapped"; + +const toEmojis = (tags) => { + if (!tags) return []; + return tags.filter((tag) => tag in emojisMapped).map((tag) => emojisMapped[tag]); +}; + +export const formatTitle = (m) => { + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.title}`; + } + return m.title; +}; + +const formatTitleWithDefault = (m, fallback) => { + if (m.title) { + return formatTitle(m); + } + return fallback; +}; + +export const formatMessage = (m) => { + if (m.title) { + return m.message; + } + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.message}`; + } + return m.message; +}; + +const imageRegex = /\.(png|jpe?g|gif|webp)$/i; +export const isImage = (attachment) => { + if (!attachment) return false; + + // if there's a type, only take that into account + if (attachment.type) { + return attachment.type.startsWith("image/"); + } + + // otherwise, check the extension + return attachment.name?.match(imageRegex) || attachment.url?.match(imageRegex); +}; + +export const icon = "/static/images/ntfy.png"; +export const badge = "/static/images/mask-icon.svg"; + +export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { + const image = isImage(message.attachment) ? message.attachment.url : undefined; + + // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API + return [ + formatTitleWithDefault(message, defaultTitle), + { + body: formatMessage(message), + badge, + icon, + image, + timestamp: message.time * 1_000, + tag: subscriptionId, + renotify: true, + silent: false, + // This is used by the notification onclick event + data: { + message, + topicRoute, + }, + actions: message.actions + ?.filter(({ action }) => action === "view" || action === "http") + .map(({ label }) => ({ + action: label, + title: label, + })), + }, + ]; +}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index aaf89111..08710c1f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,4 +1,4 @@ -import {rawEmojis} from "./emojis"; +import { Base64 } from "js-base64"; import beep from "../sounds/beep.mp3"; import juntos from "../sounds/juntos.mp3"; import pristine from "../sounds/pristine.mp3"; @@ -7,228 +7,266 @@ import dadum from "../sounds/dadum.mp3"; import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; -import {Base64} from 'js-base64'; +import emojisMapped from "./emojisMapped"; +export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; +export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); +export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; +export const expandSecureUrl = (url) => `https://${url}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; -export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` - .replaceAll("https://", "wss://") - .replaceAll("http://", "ws://"); +export const topicUrlWs = (baseUrl, topic) => + `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); -export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; -export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); -export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; -export const expandSecureUrl = (url) => `https://${url}`; +export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`; +export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; +export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; +export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; +export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; +export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`; +export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`; +export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; +export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; +export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; +export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; +export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; -export const validUrl = (url) => { - return url.match(/^https?:\/\/.+/); -} +export const validUrl = (url) => url.match(/^https?:\/\/.+/); + +export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic); export const validTopic = (topic) => { - if (disallowedTopic(topic)) { - return false; - } - return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! -} - -export const disallowedTopic = (topic) => { - return config.disallowedTopics.includes(topic); -} - -// Format emojis (see emoji.js) -const emojis = {}; -rawEmojis.forEach(emoji => { - emoji.aliases.forEach(alias => { - emojis[alias] = emoji.emoji; - }); -}); - -const toEmojis = (tags) => { - if (!tags) return []; - else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]); -} - -export const formatTitleWithDefault = (m, fallback) => { - if (m.title) { - return formatTitle(m); - } - return fallback; + if (disallowedTopic(topic)) { + return false; + } + return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! }; -export const formatTitle = (m) => { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.title}`; - } else { - return m.title; - } -}; - -export const formatMessage = (m) => { - if (m.title) { - return m.message; - } else { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; - } else { - return m.message; - } - } +export const topicDisplayName = (subscription) => { + if (subscription.displayName) { + return subscription.displayName; + } + if (subscription.baseUrl === config.base_url) { + return subscription.topic; + } + return topicShortUrl(subscription.baseUrl, subscription.topic); }; export const unmatchedTags = (tags) => { - if (!tags) return []; - else return tags.filter(tag => !(tag in emojis)); -} + if (!tags) return []; + return tags.filter((tag) => !(tag in emojisMapped)); +}; +export const encodeBase64 = (s) => Base64.encode(s); -export const maybeWithBasicAuth = (headers, user) => { - if (user) { - headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`; - } - return headers; -} +export const encodeBase64Url = (s) => Base64.encodeURI(s); -export const basicAuth = (username, password) => { - return `Basic ${encodeBase64(`${username}:${password}`)}`; -} +export const bearerAuth = (token) => `Bearer ${token}`; -export const encodeBase64 = (s) => { - return Base64.encode(s); -} +export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`; -export const encodeBase64Url = (s) => { - return Base64.encodeURI(s); -} +export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) }); -export const maybeAppendActionErrors = (message, notification) => { - const actionErrors = (notification.actions ?? []) - .map(action => action.error) - .filter(action => !!action) - .join("\n") - if (actionErrors.length === 0) { - return message; - } else { - return `${message}\n\n${actionErrors}`; - } -} +export const maybeWithBearerAuth = (headers, token) => { + if (token) { + return withBearerAuth(headers, token); + } + return headers; +}; + +export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) }); + +export const maybeWithAuth = (headers, user) => { + if (user?.password) { + return withBasicAuth(headers, user.username, user.password); + } + if (user?.token) { + return withBearerAuth(headers, user.token); + } + return headers; +}; + +export const maybeActionErrors = (notification) => { + const actionErrors = (notification.actions ?? []) + .map((action) => action.error) + .filter((action) => !!action) + .join("\n"); + if (actionErrors.length === 0) { + return undefined; + } + return actionErrors; +}; export const shuffle = (arr) => { - let j, x; - for (let index = arr.length - 1; index > 0; index--) { - j = Math.floor(Math.random() * (index + 1)); - x = arr[index]; - arr[index] = arr[j]; - arr[j] = x; - } - return arr; -} + const returnArr = [...arr]; -export const splitNoEmpty = (s, delimiter) => { - return s - .split(delimiter) - .map(x => x.trim()) - .filter(x => x !== ""); -} + for (let index = returnArr.length - 1; index > 0; index -= 1) { + const j = Math.floor(Math.random() * (index + 1)); + [returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]]; + } + + return returnArr; +}; + +export const splitNoEmpty = (s, delimiter) => + s + .split(delimiter) + .map((x) => x.trim()) + .filter((x) => x !== ""); /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ -export const hashCode = async (s) => { - let hash = 0; - for (let i = 0; i < s.length; i++) { - const char = s.charCodeAt(i); - hash = ((hash<<5)-hash)+char; - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} +export const hashCode = (s) => { + let hash = 0; + for (let i = 0; i < s.length; i += 1) { + const char = s.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + char; + // eslint-disable-next-line no-bitwise + hash &= hash; // Convert to 32bit integer + } + return hash; +}; -export const formatShortDateTime = (timestamp) => { - return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'}) - .format(new Date(timestamp * 1000)); -} +/** + * convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`), + * which is expected by `` and `Intl.DateTimeFormat` + */ +export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-"); + +export const formatShortDateTime = (timestamp, language) => + new Intl.DateTimeFormat(getKebabCaseLangStr(language), { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(timestamp * 1000)); + +export const formatShortDate = (timestamp, language) => + new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000)); export const formatBytes = (bytes, decimals = 2) => { - if (bytes === 0) return '0 bytes'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} + if (bytes === 0) return "0 bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; +}; + +export const formatNumber = (n) => { + if (n === 0) { + return n; + } + if (n % 1000 === 0) { + return `${n / 1000}k`; + } + return n.toLocaleString(); +}; + +export const formatPrice = (n) => { + if (n % 100 === 0) { + return `$${n / 100}`; + } + return `$${(n / 100).toPrecision(2)}`; +}; export const openUrl = (url) => { - window.open(url, "_blank", "noopener,noreferrer"); + window.open(url, "_blank", "noopener,noreferrer"); }; export const sounds = { - "ding": { - file: ding, - label: "Ding" - }, - "juntos": { - file: juntos, - label: "Juntos" - }, - "pristine": { - file: pristine, - label: "Pristine" - }, - "dadum": { - file: dadum, - label: "Dadum" - }, - "pop": { - file: pop, - label: "Pop" - }, - "pop-swoosh": { - file: popSwoosh, - label: "Pop swoosh" - }, - "beep": { - file: beep, - label: "Beep" - } + ding: { + file: ding, + label: "Ding", + }, + juntos: { + file: juntos, + label: "Juntos", + }, + pristine: { + file: pristine, + label: "Pristine", + }, + dadum: { + file: dadum, + label: "Dadum", + }, + pop: { + file: pop, + label: "Pop", + }, + "pop-swoosh": { + file: popSwoosh, + label: "Pop swoosh", + }, + beep: { + file: beep, + label: "Beep", + }, }; export const playSound = async (id) => { - const audio = new Audio(sounds[id].file); - return audio.play(); + const audio = new Audio(sounds[id].file); + return audio.play(); }; // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +// eslint-disable-next-line func-style export async function* fetchLinesIterator(fileURL, headers) { - const utf8Decoder = new TextDecoder('utf-8'); - const response = await fetch(fileURL, { - headers: headers - }); - const reader = response.body.getReader(); - let { value: chunk, done: readerDone } = await reader.read(); - chunk = chunk ? utf8Decoder.decode(chunk) : ''; + const utf8Decoder = new TextDecoder("utf-8"); + const response = await fetch(fileURL, { + headers, + }); + const reader = response.body.getReader(); + let { value: chunk, done: readerDone } = await reader.read(); + chunk = chunk ? utf8Decoder.decode(chunk) : ""; - const re = /\n|\r|\r\n/gm; - let startIndex = 0; + const re = /\n|\r|\r\n/gm; + let startIndex = 0; - for (;;) { - let result = re.exec(chunk); - if (!result) { - if (readerDone) { - break; - } - let remainder = chunk.substr(startIndex); - ({ value: chunk, done: readerDone } = await reader.read()); - chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ''); - startIndex = re.lastIndex = 0; - continue; - } - yield chunk.substring(startIndex, result.index); - startIndex = re.lastIndex; - } - if (startIndex < chunk.length) { - yield chunk.substr(startIndex); // last line didn't end in a newline char + for (;;) { + const result = re.exec(chunk); + if (!result) { + if (readerDone) { + break; + } + const remainder = chunk.substr(startIndex); + // eslint-disable-next-line no-await-in-loop + ({ value: chunk, done: readerDone } = await reader.read()); + chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); + startIndex = 0; + re.lastIndex = 0; + // eslint-disable-next-line no-continue + continue; } + yield chunk.substring(startIndex, result.index); + startIndex = re.lastIndex; + } + if (startIndex < chunk.length) { + yield chunk.substr(startIndex); // last line didn't end in a newline char + } } + +export const randomAlphanumericString = (len) => { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let id = ""; + for (let i = 0; i < len; i += 1) { + // eslint-disable-next-line no-bitwise + id += alphabet[(Math.random() * alphabet.length) | 0]; + } + return id; +}; + +export const urlB64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; i += 1) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx new file mode 100644 index 00000000..319353df --- /dev/null +++ b/web/src/components/Account.jsx @@ -0,0 +1,1131 @@ +import * as React from "react"; +import { useContext, useState } from "react"; +import { + Alert, + CardActions, + CardContent, + Chip, + FormControl, + FormControlLabel, + LinearProgress, + Link, + Portal, + Radio, + RadioGroup, + Select, + Snackbar, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + useMediaQuery, + Tooltip, + Typography, + Container, + Card, + Button, + Dialog, + DialogTitle, + DialogContent, + TextField, + IconButton, + MenuItem, + DialogContentText, + useTheme, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import { Trans, useTranslation } from "react-i18next"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import humanizeDuration from "humanize-duration"; +import CelebrationIcon from "@mui/icons-material/Celebration"; +import CloseIcon from "@mui/icons-material/Close"; +import { ContentCopy, Public } from "@mui/icons-material"; +import AddIcon from "@mui/icons-material/Add"; +import routes from "./routes"; +import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; +import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; +import { Pref, PrefGroup } from "./Pref"; +import db from "../app/db"; +import UpgradeDialog from "./UpgradeDialog"; +import { AccountContext } from "./App"; +import DialogFooter from "./DialogFooter"; +import { Paragraph } from "./styles"; +import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; +import { ProChip } from "./SubscriptionPopup"; +import session from "../app/Session"; + +const Account = () => { + if (!session.exists()) { + window.location.href = routes.app; + return <>; + } + return ( + + + + + + + + + ); +}; + +const Basics = () => { + const { t } = useTranslation(); + return ( + + + {t("account_basics_title")} + + + + + + + + + ); +}; + +const Username = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const labelId = "prefUsername"; + + return ( + +
+ {session.username()} + {account?.role === Role.ADMIN ? ( + <> + {" "} + + 👑 + + + ) : ( + "" + )} +
+
+ ); +}; + +const ChangePassword = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const labelId = "prefChangePassword"; + + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + return ( + +
+ + ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ + + + + +
+ +
+ ); +}; + +const ChangePasswordDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleDialogSubmit = async () => { + try { + console.debug(`[Account] Changing password`); + await accountApi.changePassword(currentPassword, newPassword); + props.onClose(); + } catch (e) { + console.log(`[Account] Error changing password`, e); + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_basics_password_dialog_title")} + + setCurrentPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setNewPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setConfirmPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + +const AccountType = () => { + const { t, i18n } = useTranslation(); + const { account } = useContext(AccountContext); + const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [showPortalError, setShowPortalError] = useState(false); + + if (!account) { + return <>; + } + + const handleUpgradeClick = () => { + setUpgradeDialogKey((k) => k + 1); + setUpgradeDialogOpen(true); + }; + + const handleManageBilling = async () => { + try { + const response = await accountApi.createBillingPortalSession(); + window.open(response.redirect_url, "billing_portal"); + } catch (e) { + console.log(`[Account] Error opening billing portal`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setShowPortalError(true); + } + } + }; + + let accountType; + if (account.role === Role.ADMIN) { + const tierSuffix = account.tier + ? t("account_basics_tier_admin_suffix_with_tier", { + tier: account.tier.name, + }) + : t("account_basics_tier_admin_suffix_no_tier"); + accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; + } else if (!account.tier) { + accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic"); + } else { + accountType = account.tier.name; + if (account.billing?.interval === SubscriptionInterval.MONTH) { + accountType += ` (${t("account_basics_tier_interval_monthly")})`; + } else if (account.billing?.interval === SubscriptionInterval.YEAR) { + accountType += ` (${t("account_basics_tier_interval_yearly")})`; + } + } + + return ( + 0} + title={t("account_basics_tier_title")} + description={t("account_basics_tier_description")} + > +
+ {accountType} + {account.billing?.paid_until && !account.billing?.cancel_at && ( + + + + + + )} + {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && ( + + )} + {config.enable_payments && account.role === Role.USER && account.billing?.subscription && ( + + )} + {config.enable_payments && account.role === Role.USER && account.billing?.customer && ( + + )} + {config.enable_payments && ( + setUpgradeDialogOpen(false)} + /> + )} +
+ {account.billing?.status === SubscriptionStatus.PAST_DUE && ( + + {t("account_basics_tier_payment_overdue")} + + )} + {account.billing?.cancel_at > 0 && ( + + {t("account_basics_tier_canceled_subscription", { + date: formatShortDate(account.billing.cancel_at, i18n.language), + })} + + )} + + setShowPortalError(false)} + message={t("account_usage_cannot_create_portal_session")} + /> + +
+ ); +}; + +const PhoneNumbers = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [snackOpen, setSnackOpen] = useState(false); + const labelId = "prefPhoneNumbers"; + + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleCopy = (phoneNumber) => { + navigator.clipboard.writeText(phoneNumber); + setSnackOpen(true); + }; + + const handleDelete = async (phoneNumber) => { + try { + await accountApi.deletePhoneNumber(phoneNumber); + } catch (e) { + console.log(`[Account] Error deleting phone number`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } + }; + + if (!config.enable_calls) { + return null; + } + + if (account?.limits.calls === 0) { + return ( + + {t("account_basics_phone_numbers_title")} + {config.enable_payments && } + + } + description={t("account_basics_phone_numbers_description")} + > + {t("account_usage_calls_none")} + + ); + } + + return ( + +
+ {account?.phone_numbers?.map((phoneNumber) => ( + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> + ))} + {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} + + + +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> + +
+ ); +}; + +const AddPhoneNumberDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [channel, setChannel] = useState("sms"); + const [code, setCode] = useState(""); + const [sending, setSending] = useState(false); + const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const verifyPhone = async () => { + try { + setSending(true); + await accountApi.verifyPhoneNumber(phoneNumber, channel); + setVerificationCodeSent(true); + } catch (e) { + console.log(`[Account] Error sending verification`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + const checkVerifyPhone = async () => { + try { + setSending(true); + await accountApi.addPhoneNumber(phoneNumber, code); + props.onClose(); + } catch (e) { + console.log(`[Account] Error confirming verification`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + const handleDialogSubmit = async () => { + if (!verificationCodeSent) { + await verifyPhone(); + } else { + await checkVerifyPhone(); + } + }; + + const handleCancel = () => { + if (verificationCodeSent) { + setVerificationCodeSent(false); + setCode(""); + } else { + props.onClose(); + } + }; + + return ( + + {t("account_basics_phone_numbers_dialog_title")} + + {t("account_basics_phone_numbers_dialog_description")} + {!verificationCodeSent && ( +
+ setPhoneNumber(ev.target.value)} + inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }} + variant="standard" + sx={{ flexGrow: 1 }} + /> + + + setChannel(e.target.value)} />} + label={t("account_basics_phone_numbers_dialog_channel_sms")} + /> + setChannel(e.target.value)} />} + label={t("account_basics_phone_numbers_dialog_channel_call")} + sx={{ marginRight: 0 }} + /> + + +
+ )} + {verificationCodeSent && ( + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + variant="standard" + /> + )} +
+ + + + +
+ ); +}; + +const Stats = () => { + const { t, i18n } = useTranslation(); + const { account } = useContext(AccountContext); + + if (!account) { + return <>; + } + + const normalize = (value, max) => Math.min((value / max) * 100, 100); + + return ( + + + {t("account_usage_title")} + + + {(account.role === Role.ADMIN || account.limits.reservations > 0) && ( + +
+ + {account.stats.reservations.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.reservations.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ 0 + ? normalize(account.stats.reservations, account.limits.reservations) + : 100 + } + /> +
+ )} + + {t("account_usage_messages_title")} + + + + + + + } + > +
+ + {account.stats.messages.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.messages.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ +
+ {config.enable_emails && ( + + {t("account_usage_emails_title")} + + + + + + + } + > +
+ + {account.stats.emails.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.emails.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ +
+ )} + {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && ( + + {t("account_usage_calls_title")} + + + + + + + } + > +
+ + {account.stats.calls.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.calls.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + /> +
+ )} + +
+ + {formatBytes(account.stats.attachment_total_size)} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: formatBytes(account.limits.attachment_total_size), + }) + : t("account_usage_unlimited")} + +
+ +
+ {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && ( + + {t("account_usage_reservations_title")} + {config.enable_payments && } + + } + > + {t("account_usage_reservations_none")} + + )} + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && ( + + {t("account_usage_calls_title")} + {config.enable_payments && } + + } + > + {t("account_usage_calls_none")} + + )} +
+ {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( + {t("account_usage_basis_ip_description")} + )} +
+ ); +}; + +const InfoIcon = () => ( + +); + +const Tokens = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const tokens = account?.tokens || []; + + const handleCreateClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + return ( + + + + {t("account_tokens_title")} + + + , + }} + /> + +
{tokens?.length > 0 && }
+
+ + + + +
+ ); +}; + +const TokensTable = (props) => { + const { t, i18n } = useTranslation(); + const [snackOpen, setSnackOpen] = useState(false); + const [upsertDialogKey, setUpsertDialogKey] = useState(0); + const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); + + const tokens = (props.tokens || []).sort((a, b) => { + if (a.token === session.token()) { + return -1; + } + if (b.token === session.token()) { + return 1; + } + return a.token.localeCompare(b.token); + }); + + const handleEditClick = (token) => { + setUpsertDialogKey((prev) => prev + 1); + setSelectedToken(token); + setUpsertDialogOpen(true); + }; + + const handleDialogClose = () => { + setUpsertDialogOpen(false); + setDeleteDialogOpen(false); + setSelectedToken(null); + }; + + const handleDeleteClick = async (token) => { + setSelectedToken(token); + setDeleteDialogOpen(true); + }; + + const handleCopy = async (token) => { + await navigator.clipboard.writeText(token); + setSnackOpen(true); + }; + + return ( +
TagEmoji
" >> "$1" cat "$SCRIPTDIR/emoji.json" \ - | jq -r '.[] | ""' \ + | jq -r '.[] | ""' \ | sed -n "${from},${to}p" >> "$1" echo "
TagEmoji
" + .aliases[0] + "" + .emoji + "
" + .aliases[0] + "" + .emoji + "
+ + + {t("account_tokens_table_token_header")} + {t("account_tokens_table_label_header")} + {t("account_tokens_table_expires_header")} + {t("account_tokens_table_last_access_header")} + + + + + {tokens.map((token) => ( + + + + {token.token.slice(0, 12)} + ... + + handleCopy(token.token)}> + + + + + + + {token.token === session.token() && {t("account_tokens_table_current_session")}} + {token.token !== session.token() && (token.label || "-")} + + + {token.expires ? formatShortDateTime(token.expires, i18n.language) : {t("account_tokens_table_never_expires")}} + + +
+ {formatShortDateTime(token.last_access, i18n.language)} + + openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> + + + +
+
+ + {token.token !== session.token() && ( + <> + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> + + + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> + + + + )} + {token.token === session.token() && ( + + + + + + + + + + + )} + +
+ ))} +
+ + setSnackOpen(false)} + message={t("account_tokens_table_copied_to_clipboard")} + /> + + + +
+ ); +}; + +const TokenDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [label, setLabel] = useState(props.token?.label || ""); + const [expires, setExpires] = useState(props.token ? -1 : 0); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const editMode = !!props.token; + + const handleSubmit = async () => { + try { + if (editMode) { + await accountApi.updateToken(props.token.token, label, expires); + } else { + await accountApi.createToken(label, expires); + } + props.onClose(); + } catch (e) { + console.log(`[Account] Error creating token`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} + + setLabel(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + + + + ); +}; + +const TokenDeleteDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + try { + await accountApi.deleteToken(props.token.token); + props.onClose(); + } catch (e) { + console.log(`[Account] Error deleting token`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_tokens_delete_dialog_title")} + + + + + + + + + + + ); +}; + +const Delete = () => { + const { t } = useTranslation(); + return ( + + + {t("account_delete_title")} + + + + + + ); +}; + +const DeleteAccount = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + return ( + +
+ +
+ +
+ ); +}; + +const DeleteAccountDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleSubmit = async () => { + try { + await accountApi.delete(password); + await db().delete(); + console.debug(`[Account] Account deleted`); + await session.resetAndRedirect(routes.app); + } catch (e) { + console.log(`[Account] Error deleting account`, e); + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_delete_title")} + + {t("account_delete_dialog_description")} + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + {account?.billing?.subscription && ( + + {t("account_delete_dialog_billing_warning")} + + )} + + + + + + + ); +}; + +export default Account; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js deleted file mode 100644 index 30ab271e..00000000 --- a/web/src/components/ActionBar.js +++ /dev/null @@ -1,225 +0,0 @@ -import AppBar from "@mui/material/AppBar"; -import Navigation from "./Navigation"; -import Toolbar from "@mui/material/Toolbar"; -import IconButton from "@mui/material/IconButton"; -import MenuIcon from "@mui/icons-material/Menu"; -import Typography from "@mui/material/Typography"; -import * as React from "react"; -import {useEffect, useRef, useState} from "react"; -import Box from "@mui/material/Box"; -import {formatShortDateTime, shuffle, topicShortUrl} from "../app/utils"; -import {useLocation, useNavigate} from "react-router-dom"; -import ClickAwayListener from '@mui/material/ClickAwayListener'; -import Grow from '@mui/material/Grow'; -import Paper from '@mui/material/Paper'; -import Popper from '@mui/material/Popper'; -import MenuItem from '@mui/material/MenuItem'; -import MenuList from '@mui/material/MenuList'; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import NotificationsIcon from '@mui/icons-material/Notifications'; -import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; -import api from "../app/Api"; -import routes from "./routes"; -import subscriptionManager from "../app/SubscriptionManager"; -import logo from "../img/ntfy.svg"; -import {useTranslation} from "react-i18next"; -import {Portal, Snackbar} from "@mui/material"; - -const ActionBar = (props) => { - const { t } = useTranslation(); - const location = useLocation(); - let title = "ntfy"; - if (props.selected) { - title = topicShortUrl(props.selected.baseUrl, props.selected.topic); - } else if (location.pathname === "/settings") { - title = t("action_bar_settings"); - } - return ( - Navigation (1200), but < Dialog (1300) - ml: { sm: `${Navigation.width}px` } - }}> - - - - - - - {title} - - {props.selected && - } - - - ); -}; - -// Originally from https://mui.com/components/menus/#MenuListComposition.js -const SettingsIcons = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); - const anchorRef = useRef(null); - const subscription = props.subscription; - - const handleToggleOpen = () => { - setOpen((prevOpen) => !prevOpen); - }; - - const handleToggleMute = async () => { - const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future - await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); - } - - const handleClose = (event) => { - if (anchorRef.current && anchorRef.current.contains(event.target)) { - return; - } - setOpen(false); - }; - - const handleClearAll = async (event) => { - handleClose(event); - console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`); - await subscriptionManager.deleteNotifications(props.subscription.id); - }; - - const handleUnsubscribe = async (event) => { - console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`); - handleClose(event); - await subscriptionManager.remove(props.subscription.id); - const newSelected = await subscriptionManager.first(); // May be undefined - if (newSelected) { - navigate(routes.forSubscription(newSelected)); - } else { - navigate(routes.root); - } - }; - - const handleSendTestMessage = async () => { - const baseUrl = props.subscription.baseUrl; - const topic = props.subscription.topic; - const tags = shuffle([ - "grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern", - "de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"]) - .slice(0, Math.round(Math.random() * 4)); - const priority = shuffle([1, 2, 3, 4, 5])[0]; - const title = shuffle([ - "", - "", - "", // Higher chance of no title - "Oh my, another test message?", - "Titles are optional, did you know that?", - "ntfy is open source, and will always be free. Cool, right?", - "I don't really like apples", - "My favorite TV show is The Wire. You should watch it!", - "You can attach files and URLs to messages too", - "You can delay messages up to 3 days" - ])[0]; - const nowSeconds = Math.round(Date.now()/1000); - const message = shuffle([ - `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, - `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, - `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, - `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, - `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, - `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, - `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?` - ])[0]; - try { - await api.publish(baseUrl, topic, message, { - title: title, - priority: priority, - tags: tags - }); - } catch (e) { - console.log(`[ActionBar] Error publishing message`, e); - setSnackOpen(true); - } - setOpen(false); - } - - const handleListKeyDown = (event) => { - if (event.key === 'Tab') { - event.preventDefault(); - setOpen(false); - } else if (event.key === 'Escape') { - setOpen(false); - } - } - - // return focus to the button when we transitioned from !open -> open - const prevOpen = useRef(open); - useEffect(() => { - if (prevOpen.current === true && open === false) { - anchorRef.current.focus(); - } - prevOpen.current = open; - }, [open]); - - return ( - <> - - {subscription.mutedUntil ? : } - - - - - - {({TransitionProps, placement}) => ( - - - - - {t("action_bar_send_test_notification")} - {t("action_bar_clear_notifications")} - {t("action_bar_unsubscribe")} - - - - - )} - - - setSnackOpen(false)} - message={t("message_bar_error_publishing")} - /> - - - ); -}; - -export default ActionBar; diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx new file mode 100644 index 00000000..1f41aac0 --- /dev/null +++ b/web/src/components/ActionBar.jsx @@ -0,0 +1,192 @@ +import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon, useTheme } from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; +import * as React from "react"; +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import NotificationsIcon from "@mui/icons-material/Notifications"; +import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; +import { useTranslation } from "react-i18next"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import { Logout, Person, Settings } from "@mui/icons-material"; +import session from "../app/Session"; +import logo from "../img/ntfy.svg"; +import subscriptionManager from "../app/SubscriptionManager"; +import routes from "./routes"; +import db from "../app/db"; +import { topicDisplayName } from "../app/utils"; +import Navigation from "./Navigation"; +import accountApi from "../app/AccountApi"; +import PopupMenu from "./PopupMenu"; +import { SubscriptionPopup } from "./SubscriptionPopup"; +import { useIsLaunchedPWA } from "./hooks"; + +const ActionBar = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const location = useLocation(); + const isLaunchedPWA = useIsLaunchedPWA(); + + let title = "ntfy"; + if (props.selected) { + title = topicDisplayName(props.selected); + } else if (location.pathname === routes.settings) { + title = t("action_bar_settings"); + } else if (location.pathname === routes.account) { + title = t("action_bar_account"); + } + + const getActionBarBackground = () => { + if (isLaunchedPWA) { + return "#317f6f"; + } + + switch (theme.palette.mode) { + case "dark": + return "linear-gradient(150deg, #203631 0%, #2a6e60 100%)"; + + case "light": + default: + return "linear-gradient(150deg, #338574 0%, #56bda8 100%)"; + } + }; + + return ( + Navigation (1200), but < Dialog (1300) + ml: { sm: `${Navigation.width}px` }, + }} + > + + + + + + + {title} + + {props.selected && } + + + + ); +}; + +const SettingsIcons = (props) => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const { subscription } = props; + + const handleToggleMute = async () => { + const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future + await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); + }; + + return ( + <> + + {subscription.mutedUntil ? : } + + setAnchorEl(ev.currentTarget)} + aria-label={t("action_bar_toggle_action_menu")} + > + + + setAnchorEl(null)} /> + + ); +}; + +const ProfileIcon = () => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const navigate = useNavigate(); + + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleLogout = async () => { + try { + await accountApi.logout(); + await db().delete(); + } finally { + await session.resetAndRedirect(routes.app); + } + }; + + return ( + <> + {session.exists() && ( + + + + )} + {!session.exists() && config.enable_login && ( + + )} + {!session.exists() && config.enable_signup && ( + + )} + + navigate(routes.account)}> + + + + {session.username()} + + + navigate(routes.settings)}> + + + + {t("action_bar_profile_settings")} + + + + + + {t("action_bar_profile_logout")} + + + + ); +}; + +export default ActionBar; diff --git a/web/src/components/App.js b/web/src/components/App.js deleted file mode 100644 index 1ecf878c..00000000 --- a/web/src/components/App.js +++ /dev/null @@ -1,146 +0,0 @@ -import * as React from 'react'; -import { Suspense } from "react"; -import {useEffect, useState} from 'react'; -import Box from '@mui/material/Box'; -import {ThemeProvider} from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; -import Toolbar from '@mui/material/Toolbar'; -import Notifications from "./Notifications"; -import theme from "./theme"; -import Navigation from "./Navigation"; -import ActionBar from "./ActionBar"; -import notifier from "../app/Notifier"; -import Preferences from "./Preferences"; -import {useLiveQuery} from "dexie-react-hooks"; -import subscriptionManager from "../app/SubscriptionManager"; -import userManager from "../app/UserManager"; -import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom"; -import {expandUrl} from "../app/utils"; -import ErrorBoundary from "./ErrorBoundary"; -import routes from "./routes"; -import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks"; -import PublishDialog from "./PublishDialog"; -import Messaging from "./Messaging"; -import "./i18n"; // Translations! -import {Backdrop, CircularProgress} from "@mui/material"; - -// TODO races when two tabs are open -// TODO investigate service workers - -const App = () => { - return ( - }> - - - - - - }> - }/> - }/> - }/> - }/> - - - - - - - ); -} - -const AllSubscriptions = () => { - const { subscriptions } = useOutletContext(); - return ; -}; - -const SingleSubscription = () => { - const { subscriptions, selected } = useOutletContext(); - useAutoSubscribe(subscriptions, selected); - return ; -}; - -const Layout = () => { - const params = useParams(); - const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); - const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); - const users = useLiveQuery(() => userManager.all()); - const subscriptions = useLiveQuery(() => subscriptionManager.all()); - const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0; - const [selected] = (subscriptions || []).filter(s => { - return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) - || (window.location.origin === s.baseUrl && params.topic === s.topic) - }); - - useConnectionListeners(subscriptions, users); - useBackgroundProcesses(); - useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); - - return ( - - - setMobileDrawerOpen(!mobileDrawerOpen)} - /> - setMobileDrawerOpen(!mobileDrawerOpen)} - onNotificationGranted={setNotificationsGranted} - onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} - /> -
- - -
- -
- ); -} - -const Main = (props) => { - return ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - {props.children} - - ); -}; - -const Loader = () => ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - - -); - -const updateTitle = (newNotificationsCount) => { - document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; -} - -export default App; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx new file mode 100644 index 00000000..7f84b7de --- /dev/null +++ b/web/src/components/App.jsx @@ -0,0 +1,172 @@ +import * as React from "react"; +import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; +import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material"; +import { useLiveQuery } from "dexie-react-hooks"; +import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AllSubscriptions, SingleSubscription } from "./Notifications"; +import { darkTheme, lightTheme } from "./theme"; +import Navigation from "./Navigation"; +import ActionBar from "./ActionBar"; +import Preferences from "./Preferences"; +import subscriptionManager from "../app/SubscriptionManager"; +import userManager from "../app/UserManager"; +import { expandUrl, getKebabCaseLangStr } from "../app/utils"; +import ErrorBoundary from "./ErrorBoundary"; +import routes from "./routes"; +import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks"; +import PublishDialog from "./PublishDialog"; +import Messaging from "./Messaging"; +import Login from "./Login"; +import Signup from "./Signup"; +import Account from "./Account"; +import initI18n from "../app/i18n"; // Translations! +import prefs, { THEME } from "../app/Prefs"; +import RTLCacheProvider from "./RTLCacheProvider"; + +initI18n(); + +export const AccountContext = createContext(null); + +const darkModeEnabled = (prefersDarkMode, themePreference) => { + switch (themePreference) { + case THEME.DARK: + return true; + + case THEME.LIGHT: + return false; + + case THEME.SYSTEM: + default: + return prefersDarkMode; + } +}; + +const App = () => { + const { i18n } = useTranslation(); + const languageDir = i18n.dir(); + + const [account, setAccount] = useState(null); + const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); + const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const themePreference = useLiveQuery(() => prefs.theme()); + const theme = React.useMemo( + () => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }), + [prefersDarkMode, themePreference, languageDir] + ); + + useEffect(() => { + document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language)); + document.dir = languageDir; + }, [i18n.language, languageDir]); + + return ( + }> + + + + + + + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + + + + + + + + ); +}; + +const updateTitle = (newNotificationsCount) => { + document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; + window.navigator.setAppBadge?.(newNotificationsCount); +}; + +const Layout = () => { + const params = useParams(); + const { account, setAccount } = useContext(AccountContext); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const users = useLiveQuery(() => userManager.all()); + const subscriptions = useLiveQuery(() => subscriptionManager.all()); + const webPushTopics = useWebPushTopics(); + const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); + const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; + const [selected] = (subscriptionsWithoutInternal || []).filter( + (s) => + (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) || + (config.base_url === s.baseUrl && params.topic === s.topic) + ); + + useConnectionListeners(account, subscriptions, users, webPushTopics); + useAccountListener(setAccount); + useBackgroundProcesses(); + useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); + + return ( + + setMobileDrawerOpen(!mobileDrawerOpen)} /> + setMobileDrawerOpen(!mobileDrawerOpen)} + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} + /> +
+ + +
+ +
+ ); +}; + +const Main = (props) => ( + (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), + }} + > + {props.children} + +); + +const Loader = () => ( + (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), + }} + > + + +); + +export default App; diff --git a/web/src/components/AttachmentIcon.js b/web/src/components/AttachmentIcon.js deleted file mode 100644 index 337760b7..00000000 --- a/web/src/components/AttachmentIcon.js +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from "react"; -import Box from "@mui/material/Box"; -import fileDocument from "../img/file-document.svg"; -import fileImage from "../img/file-image.svg"; -import fileVideo from "../img/file-video.svg"; -import fileAudio from "../img/file-audio.svg"; -import fileApp from "../img/file-app.svg"; -import {useTranslation} from "react-i18next"; - -const AttachmentIcon = (props) => { - const { t } = useTranslation(); - const type = props.type; - let imageFile, imageLabel; - if (!type) { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_image"); - } else if (type.startsWith('image/')) { - imageFile = fileImage; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith('video/')) { - imageFile = fileVideo; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith('audio/')) { - imageFile = fileAudio; - imageLabel = t("notifications_attachment_file_audio"); - } else if (type === "application/vnd.android.package-archive") { - imageFile = fileApp; - imageLabel = t("notifications_attachment_file_app"); - } else { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_document"); - } - return ( - - ); -} - -export default AttachmentIcon; diff --git a/web/src/components/AttachmentIcon.jsx b/web/src/components/AttachmentIcon.jsx new file mode 100644 index 00000000..9a2581e9 --- /dev/null +++ b/web/src/components/AttachmentIcon.jsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { Box } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import fileDocument from "../img/file-document.svg"; +import fileImage from "../img/file-image.svg"; +import fileVideo from "../img/file-video.svg"; +import fileAudio from "../img/file-audio.svg"; +import fileApp from "../img/file-app.svg"; + +const AttachmentIcon = (props) => { + const { t } = useTranslation(); + const { type } = props; + let imageFile; + let imageLabel; + if (!type) { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_image"); + } else if (type.startsWith("image/")) { + imageFile = fileImage; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith("video/")) { + imageFile = fileVideo; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith("audio/")) { + imageFile = fileAudio; + imageLabel = t("notifications_attachment_file_audio"); + } else if (type === "application/vnd.android.package-archive") { + imageFile = fileApp; + imageLabel = t("notifications_attachment_file_app"); + } else { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_document"); + } + return ( + + ); +}; + +export default AttachmentIcon; diff --git a/web/src/components/AvatarBox.jsx b/web/src/components/AvatarBox.jsx new file mode 100644 index 00000000..37c85d4e --- /dev/null +++ b/web/src/components/AvatarBox.jsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Avatar, Box, styled } from "@mui/material"; +import logo from "../img/ntfy-filled.svg"; + +const AvatarBoxContainer = styled(Box)` + display: flex; + flex-grow: 1; + justify-content: center; + flex-direction: column; + align-content: center; + align-items: center; + height: 100dvh; + max-width: min(400px, 90dvw); + margin: auto; +`; +const AvatarBox = (props) => ( + + + {props.children} + +); + +export default AvatarBox; diff --git a/web/src/components/DialogFooter.js b/web/src/components/DialogFooter.js deleted file mode 100644 index 68d17c73..00000000 --- a/web/src/components/DialogFooter.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from "react"; -import Box from "@mui/material/Box"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogActions from "@mui/material/DialogActions"; - -const DialogFooter = (props) => { - return ( - - - {props.status} - - - {props.children} - - - ); -}; - -export default DialogFooter; diff --git a/web/src/components/DialogFooter.jsx b/web/src/components/DialogFooter.jsx new file mode 100644 index 00000000..bcaf4cfc --- /dev/null +++ b/web/src/components/DialogFooter.jsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { Box, DialogContentText, DialogActions } from "@mui/material"; + +const DialogFooter = (props) => ( + + + {props.status} + + {props.children} + +); + +export default DialogFooter; diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.js deleted file mode 100644 index 9b29e8f0..00000000 --- a/web/src/components/EmojiPicker.js +++ /dev/null @@ -1,179 +0,0 @@ -import * as React from 'react'; -import {useRef, useState} from 'react'; -import Typography from '@mui/material/Typography'; -import {rawEmojis} from '../app/emojis'; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material"; -import IconButton from "@mui/material/IconButton"; -import {Close} from "@mui/icons-material"; -import Popper from "@mui/material/Popper"; -import {splitNoEmpty} from "../app/utils"; -import {useTranslation} from "react-i18next"; - -// Create emoji list by category and create a search base (string with all search words) -// -// This also filters emojis that are not supported by Desktop Chrome. -// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. - -const emojisByCategory = {}; -const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); -const maxSupportedVersionForDesktopChrome = 11; -rawEmojis.forEach(emoji => { - if (!emojisByCategory[emoji.category]) { - emojisByCategory[emoji.category] = []; - } - try { - const unicodeVersion = parseFloat(emoji.unicode_version); - const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; - if (supportedEmoji) { - const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; - const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; - emojisByCategory[emoji.category].push(emojiWithSearchBase); - } - } catch (e) { - // Nothing. Ignore. - } -}); - -const EmojiPicker = (props) => { - const { t } = useTranslation(); - const open = Boolean(props.anchorEl); - const [search, setSearch] = useState(""); - const searchRef = useRef(null); - const searchFields = splitNoEmpty(search.toLowerCase(), " "); - - const handleSearchClear = () => { - setSearch(""); - searchRef.current?.focus(); - }; - - return ( - - {({ TransitionProps }) => ( - - - - setSearch(ev.target.value)} - type="text" - variant="standard" - fullWidth - sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} - inputProps={{ - role: "searchbox", - "aria-label": t("emoji_picker_search_placeholder") - }} - InputProps={{ - endAdornment: - - - - - - }} - /> - - {Object.keys(emojisByCategory).map(category => - - )} - - - - - )} - - ); -}; - -const Category = (props) => { - const showTitle = props.search.length === 0; - return ( - <> - {showTitle && - - {props.title} - - } - {props.emojis.map(emoji => - props.onPick(emoji.aliases[0])} - /> - )} - - ); -}; - -const Emoji = (props) => { - const emoji = props.emoji; - const matches = emojiMatches(emoji, props.search); - const title = `${emoji.description} (${emoji.aliases[0]})`; - return ( - - {props.emoji.emoji} - - ); -}; - -const EmojiDiv = styled("div")({ - fontSize: "30px", - width: "30px", - height: "30px", - marginTop: "8px", - marginBottom: "8px", - marginRight: "8px", - lineHeight: "30px", - cursor: "pointer", - opacity: 0.85, - "&:hover": { - opacity: 1 - } -}); - -const emojiMatches = (emoji, words) => { - if (words.length === 0) { - return true; - } - for (const word of words) { - if (emoji.searchBase.indexOf(word) === -1) { - return false; - } - } - return true; -} - -export default EmojiPicker; diff --git a/web/src/components/EmojiPicker.jsx b/web/src/components/EmojiPicker.jsx new file mode 100644 index 00000000..d1fb1706 --- /dev/null +++ b/web/src/components/EmojiPicker.jsx @@ -0,0 +1,158 @@ +import * as React from "react"; +import { useRef, useState } from "react"; +import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material"; +import { Close } from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { splitNoEmpty } from "../app/utils"; +import { rawEmojis } from "../app/emojis"; + +// Create emoji list by category and create a search base (string with all search words) +// +// This also filters emojis that are not supported by Desktop Chrome. +// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. + +const emojisByCategory = {}; +const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); +const maxSupportedVersionForDesktopChrome = 11; +rawEmojis.forEach((emoji) => { + if (!emojisByCategory[emoji.category]) { + emojisByCategory[emoji.category] = []; + } + try { + const unicodeVersion = parseFloat(emoji.unicode_version); + const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; + if (supportedEmoji) { + const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; + const emojiWithSearchBase = { ...emoji, searchBase }; + emojisByCategory[emoji.category].push(emojiWithSearchBase); + } + } catch (e) { + // Nothing. Ignore. + } +}); + +const EmojiPicker = (props) => { + const { t } = useTranslation(); + const open = Boolean(props.anchorEl); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + const searchFields = splitNoEmpty(search.toLowerCase(), " "); + + const handleSearchClear = () => { + setSearch(""); + searchRef.current?.focus(); + }; + + return ( + + {({ TransitionProps }) => ( + + + + setSearch(ev.target.value)} + type="text" + variant="standard" + fullWidth + sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} + inputProps={{ + role: "searchbox", + "aria-label": t("emoji_picker_search_placeholder"), + }} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + {Object.keys(emojisByCategory).map((category) => ( + + ))} + + + + + )} + + ); +}; + +const Category = (props) => { + const showTitle = props.search.length === 0; + return ( + <> + {showTitle && ( + + {props.title} + + )} + {props.emojis.map((emoji) => ( + props.onPick(emoji.aliases[0])} /> + ))} + + ); +}; + +const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word)); + +const Emoji = (props) => { + const { emoji } = props; + const matches = emojiMatches(emoji, props.search); + const title = `${emoji.description} (${emoji.aliases[0]})`; + return ( + + {props.emoji.emoji} + + ); +}; + +const EmojiDiv = styled("div")({ + fontSize: "30px", + width: "30px", + height: "30px", + marginTop: "8px", + marginBottom: "8px", + marginRight: "8px", + lineHeight: "30px", + cursor: "pointer", + opacity: 0.85, + "&:hover": { + opacity: 1, + }, +}); + +export default EmojiPicker; diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js deleted file mode 100644 index c6d789a3..00000000 --- a/web/src/components/ErrorBoundary.js +++ /dev/null @@ -1,129 +0,0 @@ -import * as React from "react"; -import StackTrace from "stacktrace-js"; -import {CircularProgress, Link} from "@mui/material"; -import Button from "@mui/material/Button"; -import {Trans, withTranslation} from "react-i18next"; - -class ErrorBoundaryImpl extends React.Component { - constructor(props) { - super(props); - this.state = { - error: false, - originalStack: null, - niceStack: null, - unsupportedIndexedDB: false - }; - } - - componentDidCatch(error, info) { - console.error("[ErrorBoundary] Error caught", error, info); - - // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see - // - https://github.com/dexie/Dexie.js/issues/312 - // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 - const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || - (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); - - if (isUnsupportedIndexedDB) { - this.handleUnsupportedIndexedDB(); - } else { - this.handleError(error, info); - } - } - - handleError(error, info) { - // Immediately render original stack trace - const prettierOriginalStack = info.componentStack - .trim() - .split("\n") - .map(line => ` at ${line}`) - .join("\n"); - this.setState({ - error: true, - originalStack: `${error.toString()}\n${prettierOriginalStack}` - }); - - // Fetch additional info and a better stack trace - StackTrace.fromError(error).then(stack => { - console.error("[ErrorBoundary] Stacktrace fetched", stack); - const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); - this.setState({ niceStack }); - }); - } - - handleUnsupportedIndexedDB() { - this.setState({ - error: true, - unsupportedIndexedDB: true - }); - } - - copyStack() { - let stack = ""; - if (this.state.niceStack) { - stack += `${this.state.niceStack}\n\n`; - } - stack += `${this.state.originalStack}\n`; - navigator.clipboard.writeText(stack); - } - - render() { - if (this.state.error) { - if (this.state.unsupportedIndexedDB) { - return this.renderUnsupportedIndexedDB(); - } else { - return this.renderError(); - } - } - return this.props.children; - } - - renderUnsupportedIndexedDB() { - const { t } = this.props; - return ( -
-

{t("error_boundary_unsupported_indexeddb_title")} 😮

-

- , - discordLink: , - matrixLink: - }} - /> -

-
- ); - } - - renderError() { - const { t } = this.props; - return ( -
-

{t("error_boundary_title")} 😮

-

- , - discordLink: , - matrixLink: - }} - /> -

-

- -

-

{t("error_boundary_stack_trace")}

- {this.state.niceStack - ?
{this.state.niceStack}
- : <> {t("error_boundary_gathering_info")}} -
{this.state.originalStack}
-
- ); - } -} - -const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t -export default ErrorBoundary; diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..adb177c6 --- /dev/null +++ b/web/src/components/ErrorBoundary.jsx @@ -0,0 +1,138 @@ +import * as React from "react"; +import StackTrace from "stacktrace-js"; +import { CircularProgress, Link, Button } from "@mui/material"; +import { Trans, withTranslation } from "react-i18next"; + +class ErrorBoundaryImpl extends React.Component { + constructor(props) { + super(props); + this.state = { + error: false, + originalStack: null, + niceStack: null, + unsupportedIndexedDB: false, + }; + } + + componentDidCatch(error, info) { + console.error("[ErrorBoundary] Error caught", error, info); + + // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see + // - https://github.com/dexie/Dexie.js/issues/312 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 + const isUnsupportedIndexedDB = + error?.name === "InvalidStateError" || (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); + + if (isUnsupportedIndexedDB) { + this.handleUnsupportedIndexedDB(); + } else { + this.handleError(error, info); + } + } + + handleError(error, info) { + // Immediately render original stack trace + const prettierOriginalStack = info.componentStack + .trim() + .split("\n") + .map((line) => ` at ${line}`) + .join("\n"); + this.setState({ + error: true, + originalStack: `${error.toString()}\n${prettierOriginalStack}`, + }); + + // Fetch additional info and a better stack trace + StackTrace.fromError(error).then((stack) => { + console.error("[ErrorBoundary] Stacktrace fetched", stack); + const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); + const niceStack = `${error.toString()}\n${stackString}`; + this.setState({ niceStack }); + }); + } + + handleUnsupportedIndexedDB() { + this.setState({ + error: true, + unsupportedIndexedDB: true, + }); + } + + copyStack() { + let stack = ""; + if (this.state.niceStack) { + stack += `${this.state.niceStack}\n\n`; + } + stack += `${this.state.originalStack}\n`; + navigator.clipboard.writeText(stack); + } + + renderUnsupportedIndexedDB() { + const { t } = this.props; + return ( +
+

{t("error_boundary_unsupported_indexeddb_title")} 😮

+

+ , + discordLink: , + matrixLink: , + }} + /> +

+
+ ); + } + + renderError() { + const { t } = this.props; + return ( +
+

{t("error_boundary_title")} 😮

+

+ , + discordLink: , + matrixLink: , + }} + /> +

+
+ + + +
+

{t("error_boundary_stack_trace")}

+ {this.state.niceStack ? ( +
{this.state.niceStack}
+ ) : ( + <> + {t("error_boundary_gathering_info")} + + )} +
{this.state.originalStack}
+
+ ); + } + + render() { + if (this.state.error) { + if (this.state.unsupportedIndexedDB) { + return this.renderUnsupportedIndexedDB(); + } + return this.renderError(); + } + return this.props.children; + } +} + +const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t +export default ErrorBoundary; diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx new file mode 100644 index 00000000..5c1af249 --- /dev/null +++ b/web/src/components/Login.jsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { useState } from "react"; +import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import { NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import session from "../app/Session"; +import routes from "./routes"; +import { UnauthorizedError } from "../app/errors"; + +const Login = () => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + const token = await accountApi.login(user); + console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); + await session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Login] User auth for user ${user.username} failed`, e); + if (e instanceof UnauthorizedError) { + setError(t("Login failed: Invalid username or password")); + } else { + setError(e.message); + } + } + }; + if (!config.enable_login) { + return ( + + {t("login_disabled")} + + ); + } + return ( + + {t("login_title")} + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + autoComplete="current-password" + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + + {error && ( + + + {error} + + )} + + {/* This is where the password reset link would go */} + {config.enable_signup && ( +
+ + {t("login_link_signup")} + +
+ )} +
+
+
+ ); +}; + +export default Login; diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js deleted file mode 100644 index 4ba1203f..00000000 --- a/web/src/components/Messaging.js +++ /dev/null @@ -1,114 +0,0 @@ -import * as React from 'react'; -import {useState} from 'react'; -import Navigation from "./Navigation"; -import Paper from "@mui/material/Paper"; -import IconButton from "@mui/material/IconButton"; -import TextField from "@mui/material/TextField"; -import SendIcon from "@mui/icons-material/Send"; -import api from "../app/Api"; -import PublishDialog from "./PublishDialog"; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import {Portal, Snackbar} from "@mui/material"; -import {useTranslation} from "react-i18next"; - -const Messaging = (props) => { - const [message, setMessage] = useState(""); - const [dialogKey, setDialogKey] = useState(0); - - const dialogOpenMode = props.dialogOpenMode; - const subscription = props.selected; - - const handleOpenDialogClick = () => { - props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); - }; - - const handleDialogClose = () => { - props.onDialogOpenModeChange(""); - setDialogKey(prev => prev+1); - }; - - return ( - <> - {subscription && } - props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open - onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} - /> - - ); -} - -const MessageBar = (props) => { - const { t } = useTranslation(); - const subscription = props.subscription; - const [snackOpen, setSnackOpen] = useState(false); - const handleSendClick = async () => { - try { - await api.publish(subscription.baseUrl, subscription.topic, props.message); - } catch (e) { - console.log(`[MessageBar] Error publishing message`, e); - setSnackOpen(true); - } - props.onMessageChange(""); - }; - return ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - - - - props.onMessageChange(ev.target.value)} - onKeyPress={(ev) => { - if (ev.key === 'Enter') { - ev.preventDefault(); - handleSendClick(); - } - }} - /> - - - - - setSnackOpen(false)} - message={t("message_bar_error_publishing")} - /> - - - ); -}; - -export default Messaging; diff --git a/web/src/components/Messaging.jsx b/web/src/components/Messaging.jsx new file mode 100644 index 00000000..27e08dc9 --- /dev/null +++ b/web/src/components/Messaging.jsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import { useState } from "react"; +import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material"; +import SendIcon from "@mui/icons-material/Send"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import { useTranslation } from "react-i18next"; +import PublishDialog from "./PublishDialog"; +import api from "../app/Api"; +import Navigation from "./Navigation"; + +const Messaging = (props) => { + const [message, setMessage] = useState(""); + const [dialogKey, setDialogKey] = useState(0); + + const { dialogOpenMode } = props; + const subscription = props.selected; + + const handleOpenDialogClick = () => { + props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); + }; + + const handleDialogClose = () => { + props.onDialogOpenModeChange(""); + setDialogKey((prev) => prev + 1); + }; + + return ( + <> + {subscription && ( + + )} + props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open + onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} + /> + + ); +}; + +const MessageBar = (props) => { + const { t } = useTranslation(); + const { subscription } = props; + const [snackOpen, setSnackOpen] = useState(false); + const handleSendClick = async () => { + try { + await api.publish(subscription.baseUrl, subscription.topic, props.message); + } catch (e) { + console.log(`[MessageBar] Error publishing message`, e); + setSnackOpen(true); + } + props.onMessageChange(""); + }; + return ( + (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), + }} + > + + + + props.onMessageChange(ev.target.value)} + onKeyPress={(ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleSendClick(); + } + }} + /> + + + + + setSnackOpen(false)} + message={t("message_bar_error_publishing")} + /> + + + ); +}; + +export default Messaging; diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js deleted file mode 100644 index cafad049..00000000 --- a/web/src/components/Navigation.js +++ /dev/null @@ -1,227 +0,0 @@ -import Drawer from "@mui/material/Drawer"; -import * as React from "react"; -import {useState} from "react"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; -import ListItemText from "@mui/material/ListItemText"; -import Toolbar from "@mui/material/Toolbar"; -import Divider from "@mui/material/Divider"; -import List from "@mui/material/List"; -import SettingsIcon from "@mui/icons-material/Settings"; -import AddIcon from "@mui/icons-material/Add"; -import SubscribeDialog from "./SubscribeDialog"; -import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material"; -import Button from "@mui/material/Button"; -import Typography from "@mui/material/Typography"; -import {openUrl, topicShortUrl, topicUrl} from "../app/utils"; -import routes from "./routes"; -import {ConnectionState} from "../app/Connection"; -import {useLocation, useNavigate} from "react-router-dom"; -import subscriptionManager from "../app/SubscriptionManager"; -import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material"; -import Box from "@mui/material/Box"; -import notifier from "../app/Notifier"; -import config from "../app/config"; -import ArticleIcon from '@mui/icons-material/Article'; -import {useTranslation} from "react-i18next"; - -const navWidth = 280; - -const Navigation = (props) => { - const navigationList = ; - return ( - - {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} - - {navigationList} - - {/* Big screen drawer; persistent, shown if screen is big */} - - {navigationList} - - - ); -}; -Navigation.width = navWidth; - -const NavList = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); - const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); - - const handleSubscribeReset = () => { - setSubscribeDialogOpen(false); - setSubscribeDialogKey(prev => prev+1); - } - - const handleSubscribeSubmit = (subscription) => { - console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); - handleSubscribeReset(); - navigate(routes.forSubscription(subscription)); - handleRequestNotificationPermission(); - } - - const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted)) - }; - - const showSubscriptionsList = props.subscriptions?.length > 0; - const showNotificationNotSupportedBox = !notifier.supported(); - const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; - - return ( - <> - - - {showNotificationNotSupportedBox && } - {showNotificationGrantBox && } - {!showSubscriptionsList && - navigate(routes.root)} selected={location.pathname === config.appRoot}> - - - } - {showSubscriptionsList && - <> - {t("nav_topics_title")} - navigate(routes.root)} selected={location.pathname === config.appRoot}> - - - - - - } - navigate(routes.settings)} selected={location.pathname === routes.settings}> - - - - openUrl("/docs")}> - - - - props.onPublishMessageClick()}> - - - - setSubscribeDialogOpen(true)}> - - - - - - - ); -}; - -const SubscriptionList = (props) => { - const sortedSubscriptions = props.subscriptions.sort( (a, b) => { - return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; - }); - return ( - <> - {sortedSubscriptions.map(subscription => - )} - - ); -} - -const SubscriptionItem = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const subscription = props.subscription; - const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; - const icon = (subscription.state === ConnectionState.Connecting) - ? - : ; - const label = (subscription.baseUrl === window.location.origin) - ? subscription.topic - : topicShortUrl(subscription.baseUrl, subscription.topic); - const ariaLabel = (subscription.state === ConnectionState.Connecting) - ? `${label} (${t("nav_button_connecting")})` - : label; - const handleClick = async () => { - navigate(routes.forSubscription(subscription)); - await subscriptionManager.markNotificationsRead(subscription.id); - }; - return ( - - {icon} - - {subscription.mutedUntil > 0 && - } - - ); -}; - -const NotificationGrantAlert = (props) => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_grant_title")} - {t("alert_grant_description")} - - - - - ); -}; - -const NotificationNotSupportedAlert = () => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_not_supported_title")} - {t("alert_not_supported_description")} - - - - ); -}; - -export default Navigation; diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx new file mode 100644 index 00000000..7e30931a --- /dev/null +++ b/web/src/components/Navigation.jsx @@ -0,0 +1,428 @@ +import { + Drawer, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Divider, + List, + Alert, + AlertTitle, + Badge, + CircularProgress, + Link, + ListSubheader, + Portal, + Tooltip, + Typography, + Box, + IconButton, + Button, + useTheme, +} from "@mui/material"; +import * as React from "react"; +import { useContext, useState } from "react"; +import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; +import Person from "@mui/icons-material/Person"; +import SettingsIcon from "@mui/icons-material/Settings"; +import AddIcon from "@mui/icons-material/Add"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; +import ArticleIcon from "@mui/icons-material/Article"; +import { Trans, useTranslation } from "react-i18next"; +import CelebrationIcon from "@mui/icons-material/Celebration"; +import SubscribeDialog from "./SubscribeDialog"; +import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; +import routes from "./routes"; +import { ConnectionState } from "../app/Connection"; +import subscriptionManager from "../app/SubscriptionManager"; +import notifier from "../app/Notifier"; +import config from "../app/config"; +import session from "../app/Session"; +import accountApi, { Permission, Role } from "../app/AccountApi"; +import UpgradeDialog from "./UpgradeDialog"; +import { AccountContext } from "./App"; +import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; +import { SubscriptionPopup } from "./SubscriptionPopup"; +import { useNotificationPermissionListener } from "./hooks"; + +const navWidth = 280; + +const Navigation = (props) => { + const navigationList = ; + return ( + + {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} + + {navigationList} + + {/* Big screen drawer; persistent, shown if screen is big */} + + {navigationList} + + + ); +}; +Navigation.width = navWidth; + +const NavList = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { account } = useContext(AccountContext); + const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); + const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); + + const handleSubscribeReset = () => { + setSubscribeDialogOpen(false); + setSubscribeDialogKey((prev) => prev + 1); + }; + + const handleSubscribeSubmit = (subscription) => { + console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); + handleSubscribeReset(); + navigate(routes.forSubscription(subscription)); + }; + + const handleAccountClick = () => { + accountApi.sync(); // Dangle! + navigate(routes.account); + }; + + const isAdmin = account?.role === Role.ADMIN; + const isPaid = account?.billing?.subscription; + const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; + const showSubscriptionsList = props.subscriptions?.length > 0; + const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested()); + const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied()); + const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired(); + const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported(); + const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser + + const alertVisible = + showNotificationPermissionRequired || + showNotificationPermissionDenied || + showNotificationIOSInstallRequired || + showNotificationBrowserNotSupportedBox || + showNotificationContextNotSupportedBox; + + return ( + <> + + + {showNotificationPermissionRequired && } + {showNotificationPermissionDenied && } + {showNotificationBrowserNotSupportedBox && } + {showNotificationContextNotSupportedBox && } + {showNotificationIOSInstallRequired && } + {alertVisible && } + {!showSubscriptionsList && ( + navigate(routes.app)} selected={location.pathname === config.app_root}> + + + + + + )} + {showSubscriptionsList && ( + <> + {t("nav_topics_title")} + navigate(routes.app)} selected={location.pathname === config.app_root}> + + + + + + + + + )} + {session.exists() && ( + + + + + + + )} + navigate(routes.settings)} selected={location.pathname === routes.settings}> + + + + + + openUrl("/docs")}> + + + + + + props.onPublishMessageClick()}> + + + + + + setSubscribeDialogOpen(true)}> + + + + + + {showUpgradeBanner && ( + // The text background gradient didn't seem to do well with switching between light/dark mode, + // So adding a `key` forces React to replace the entire component when the theme changes + + )} + + + + ); +}; + +const UpgradeBanner = ({ mode }) => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClick = () => { + setDialogKey((k) => k + 1); + setDialogOpen(true); + }; + + return ( + + + + + + + + + setDialogOpen(false)} /> + + ); +}; + +const SubscriptionList = (props) => { + const sortedSubscriptions = props.subscriptions + .filter((s) => !s.internal) + .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1)); + return ( + <> + {sortedSubscriptions.map((subscription) => ( + + ))} + + ); +}; + +const SubscriptionItem = (props) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + const { subscription } = props; + const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; + const displayName = topicDisplayName(subscription); + const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName; + const icon = + subscription.state === ConnectionState.Connecting ? ( + + ) : ( + + + + ); + + const handleClick = async () => { + navigate(routes.forSubscription(subscription)); + await subscriptionManager.markNotificationsRead(subscription.id); + }; + + return ( + <> + + {icon} + + {subscription.reservation?.everyone && ( + + {subscription.reservation?.everyone === Permission.READ_WRITE && ( + + + + )} + {subscription.reservation?.everyone === Permission.READ_ONLY && ( + + + + )} + {subscription.reservation?.everyone === Permission.WRITE_ONLY && ( + + + + )} + {subscription.reservation?.everyone === Permission.DENY_ALL && ( + + + + )} + + )} + {subscription.mutedUntil > 0 && ( + + + + + + )} + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMenuAnchorEl(e.currentTarget); + }} + > + + + + + + setMenuAnchorEl(null)} /> + + + ); +}; + +const NotificationPermissionRequired = () => { + const { t } = useTranslation(); + const requestPermission = async () => { + await notifier.maybeRequestPermission(); + }; + return ( + + {t("alert_notification_permission_required_title")} + {t("alert_notification_permission_required_description")} + + + ); +}; + +const NotificationPermissionDeniedAlert = () => { + const { t } = useTranslation(); + return ( + + {t("alert_notification_permission_denied_title")} + {t("alert_notification_permission_denied_description")} + + ); +}; + +const NotificationIOSInstallRequiredAlert = () => { + const { t } = useTranslation(); + return ( + + {t("alert_notification_ios_install_required_title")} + {t("alert_notification_ios_install_required_description")} + + ); +}; + +const NotificationBrowserNotSupportedAlert = () => { + const { t } = useTranslation(); + return ( + + {t("alert_not_supported_title")} + {t("alert_not_supported_description")} + + ); +}; + +const NotificationContextNotSupportedAlert = () => { + const { t } = useTranslation(); + return ( + + {t("alert_not_supported_title")} + + , + }} + /> + + + ); +}; + +export default Navigation; diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js deleted file mode 100644 index b0bbae82..00000000 --- a/web/src/components/Notifications.js +++ /dev/null @@ -1,519 +0,0 @@ -import Container from "@mui/material/Container"; -import { - ButtonBase, - CardActions, - CardContent, - CircularProgress, - Fade, - Link, - Modal, - Snackbar, - Stack, - Tooltip -} from "@mui/material"; -import Card from "@mui/material/Card"; -import Typography from "@mui/material/Typography"; -import * as React from "react"; -import {useEffect, useState} from "react"; -import { - formatBytes, - formatMessage, - formatShortDateTime, - formatTitle, maybeAppendActionErrors, - openUrl, - shortUrl, - topicShortUrl, - unmatchedTags -} from "../app/utils"; -import IconButton from "@mui/material/IconButton"; -import CloseIcon from '@mui/icons-material/Close'; -import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; -import {useLiveQuery} from "dexie-react-hooks"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import subscriptionManager from "../app/SubscriptionManager"; -import InfiniteScroll from "react-infinite-scroll-component"; -import priority1 from "../img/priority-1.svg"; -import priority2 from "../img/priority-2.svg"; -import priority4 from "../img/priority-4.svg"; -import priority5 from "../img/priority-5.svg"; -import logoOutline from "../img/ntfy-outline.svg"; -import AttachmentIcon from "./AttachmentIcon"; -import {Trans, useTranslation} from "react-i18next"; - -const Notifications = (props) => { - if (props.mode === "all") { - return (props.subscriptions) ? : ; - } - return (props.subscription) ? : ; -} - -const AllSubscriptions = (props) => { - const subscriptions = props.subscriptions; - const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); - if (notifications === null || notifications === undefined) { - return ; - } else if (subscriptions.length === 0) { - return ; - } else if (notifications.length === 0) { - return ; - } - return ; -} - -const SingleSubscription = (props) => { - const subscription = props.subscription; - const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); - if (notifications === null || notifications === undefined) { - return ; - } else if (notifications.length === 0) { - return ; - } - return ; -} - -const NotificationList = (props) => { - const { t } = useTranslation(); - const pageSize = 20; - const notifications = props.notifications; - const [snackOpen, setSnackOpen] = useState(false); - const [maxCount, setMaxCount] = useState(pageSize); - const count = Math.min(notifications.length, maxCount); - - useEffect(() => { - return () => { - setMaxCount(pageSize); - document.getElementById("main").scrollTo(0, 0); - } - }, [props.id]); - - return ( - setMaxCount(prev => prev + pageSize)} - hasMore={count < notifications.length} - loader={<>Loading ...} - scrollThreshold={0.7} - scrollableTarget="main" - > - - - {notifications.slice(0, count).map(notification => - setSnackOpen(true)} - />)} - setSnackOpen(false)} - message={t("notifications_copied_to_clipboard")} - /> - - - - ); -} - -const NotificationItem = (props) => { - const { t } = useTranslation(); - const notification = props.notification; - const attachment = notification.attachment; - const date = formatShortDateTime(notification.time); - const otherTags = unmatchedTags(notification.tags); - const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; - const handleDelete = async () => { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id) - } - const handleCopy = (s) => { - navigator.clipboard.writeText(s); - props.onShowSnack(); - }; - const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; - const hasAttachmentActions = attachment && !expired; - const hasClickAction = notification.click; - const hasUserActions = notification.actions && notification.actions.length > 0; - const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - return ( - - - - - - - {date} - {[1,2,4,5].includes(notification.priority) && - {t("notifications_priority_x",} - {notification.new === 1 && - - - } - - {notification.title && {formatTitle(notification)}} - - {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} - - {attachment && } - {tags && {t("notifications_tags")}: {tags}} - - {showActions && - - {hasAttachmentActions && <> - - - - - - - } - {hasClickAction && <> - - - - - - - } - {hasUserActions && } - } - - ); -} - -/** - * Replace links with components; this is a combination of the genius function - * in [1] and the regex in [2]. - * - * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 - * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 - */ -const autolink = (s) => { - const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); - for (let i = 1; i < parts.length; i += 2) { - parts[i] = {shortUrl(parts[i])}; - } - return <>{parts}; -}; - -const priorityFiles = { - 1: priority1, - 2: priority2, - 4: priority4, - 5: priority5 -}; - -const Attachment = (props) => { - const { t } = useTranslation(); - const attachment = props.attachment; - const expired = attachment.expires && attachment.expires < Date.now()/1000; - const expires = attachment.expires && attachment.expires > Date.now()/1000; - const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); - - // Unexpired image - if (displayableImage) { - return ; - } - - // Anything else: Show box - const infos = []; - if (attachment.size) { - infos.push(formatBytes(attachment.size)); - } - if (expires) { - infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) })); - } - if (expired) { - infos.push(t("notifications_attachment_link_expired")); - } - const maybeInfoText = (infos.length > 0) ? <>
{infos.join(", ")} : null; - - // If expired, just show infos without click target - if (expired) { - return ( - - - - {attachment.name} - {maybeInfoText} - - - ); - } - - // Not expired - return ( - - - - - {attachment.name} - {maybeInfoText} - - - - ); -}; - -const Image = (props) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - return ( - <> - setOpen(true)} - sx={{ - marginTop: 2, - borderRadius: '4px', - boxShadow: 2, - width: 1, - maxHeight: '400px', - objectFit: 'cover', - cursor: 'pointer' - }} - /> - setOpen(false)} - BackdropComponent={LightboxBackdrop} - > - - - - - - ); -} - -const UserActions = (props) => { - return ( - <>{props.notification.actions.map(action => - )} - ); -}; - -const UserAction = (props) => { - const { t } = useTranslation(); - const notification = props.notification; - const action = props.action; - if (action.action === "broadcast") { - return ( - - - - ); - } else if (action.action === "view") { - return ( - - - - ); - } else if (action.action === "http") { - const method = action.method ?? "POST"; - const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); - return ( - - - - ); - } - return null; // Others -}; - -const performHttpAction = async (notification, action) => { - console.log(`[Notifications] Performing HTTP user action`, action); - try { - updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); - const response = await fetch(action.url, { - method: action.method ?? "POST", - headers: action.headers ?? {}, - body: action.body ?? "" - }); - console.log(`[Notifications] HTTP user action response`, response); - const success = response.status >= 200 && response.status <= 299; - if (success) { - updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); - } else { - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); - } - } catch (e) { - console.log(`[Notifications] HTTP action failed`, e); - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); - } -}; - -const updateActionStatus = (notification, action, progress, error) => { - notification.actions = notification.actions.map(a => { - if (a.id !== action.id) { - return a; - } - return { ...a, progress: progress, error: error }; - }); - subscriptionManager.updateNotification(notification); -} - -const ACTION_PROGRESS_ONGOING = 1; -const ACTION_PROGRESS_SUCCESS = 2; -const ACTION_PROGRESS_FAILED = 3; - -const ACTION_LABEL_SUFFIX = { - [ACTION_PROGRESS_ONGOING]: " …", - [ACTION_PROGRESS_SUCCESS]: " ✔", - [ACTION_PROGRESS_FAILED]: " ❌" -}; - -const NoNotifications = (props) => { - const { t } = useTranslation(); - const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_none_for_topic_title")} -
- - {t("notifications_none_for_topic_description")} - - - {t("notifications_example")}:
- - $ curl -d "Hi" {shortUrl} - -
- - - -
- ); -}; - -const NoNotificationsWithoutSubscription = (props) => { - const { t } = useTranslation(); - const subscription = props.subscriptions[0]; - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_none_for_any_title")} -
- - {t("notifications_none_for_any_description")} - - - {t("notifications_example")}:
- - $ curl -d "Hi" {shortUrl} - -
- - - -
- ); -}; - -const NoSubscriptions = () => { - const { t } = useTranslation(); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_no_subscriptions_title")} -
- - {t("notifications_no_subscriptions_description", { - linktext: t("nav_button_subscribe") - })} - - - - -
- ); -}; - -const ForMoreDetails = () => { - return ( - , - docsLink: - }} - /> - ); -}; - -const Loading = () => { - const { t } = useTranslation(); - return ( - - -
- {t("notifications_loading")} -
-
- ); -}; - -export default Notifications; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx new file mode 100644 index 00000000..0b8b2e7d --- /dev/null +++ b/web/src/components/Notifications.jsx @@ -0,0 +1,672 @@ +import { + Container, + ButtonBase, + CardActions, + CardContent, + CircularProgress, + Fade, + Link, + Modal, + Snackbar, + Stack, + Tooltip, + Card, + Typography, + IconButton, + Box, + Button, +} from "@mui/material"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; +import { useLiveQuery } from "dexie-react-hooks"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { Trans, useTranslation } from "react-i18next"; +import { useOutletContext } from "react-router-dom"; +import { useRemark } from "react-remark"; +import styled from "@emotion/styled"; +import { formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils"; +import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; +import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; +import subscriptionManager from "../app/SubscriptionManager"; +import priority1 from "../img/priority-1.svg"; +import priority2 from "../img/priority-2.svg"; +import priority4 from "../img/priority-4.svg"; +import priority5 from "../img/priority-5.svg"; +import logoOutline from "../img/ntfy-outline.svg"; +import AttachmentIcon from "./AttachmentIcon"; +import { useAutoSubscribe } from "./hooks"; + +const priorityFiles = { + 1: priority1, + 2: priority2, + 4: priority4, + 5: priority5, +}; + +export const AllSubscriptions = () => { + const { subscriptions } = useOutletContext(); + if (!subscriptions) { + return ; + } + return ; +}; + +export const SingleSubscription = () => { + const { subscriptions, selected } = useOutletContext(); + useAutoSubscribe(subscriptions, selected); + if (!selected) { + return ; + } + return ; +}; + +const AllSubscriptionsList = (props) => { + const { subscriptions } = props; + const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); + if (notifications === null || notifications === undefined) { + return ; + } + if (subscriptions.length === 0) { + return ; + } + if (notifications.length === 0) { + return ; + } + return ; +}; + +const SingleSubscriptionList = (props) => { + const { subscription } = props; + const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); + if (notifications === null || notifications === undefined) { + return ; + } + if (notifications.length === 0) { + return ; + } + return ; +}; + +const NotificationList = (props) => { + const { t } = useTranslation(); + const pageSize = 20; + const { notifications } = props; + const [snackOpen, setSnackOpen] = useState(false); + const [maxCount, setMaxCount] = useState(pageSize); + const count = Math.min(notifications.length, maxCount); + + useEffect( + () => () => { + setMaxCount(pageSize); + const main = document.getElementById("main"); + if (main) { + main.scrollTo(0, 0); + } + }, + [props.id] + ); + + return ( + setMaxCount((prev) => prev + pageSize)} + hasMore={count < notifications.length} + loader={<>Loading ...} + scrollThreshold={0.7} + scrollableTarget="main" + > + + + {notifications.slice(0, count).map((notification) => ( + setSnackOpen(true)} /> + ))} + setSnackOpen(false)} + message={t("notifications_copied_to_clipboard")} + /> + + + + ); +}; + +/** + * Replace links with components; this is a combination of the genius function + * in [1] and the regex in [2]. + * + * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 + * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 + */ +const autolink = (s) => { + const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi); + for (let i = 1; i < parts.length; i += 2) { + parts[i] = ( + + {shortUrl(parts[i])} + + ); + } + return <>{parts}; +}; + +const MarkdownContainer = styled("div")` + line-height: 1; + + h1, + h2, + h3, + h4, + h5, + h6, + p, + pre, + ul, + ol, + blockquote { + margin: 0; + } + + p { + line-height: 1.2; + } + + blockquote, + pre { + border-radius: 3px; + background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")}; + } + + pre { + padding: 0.9rem; + } + + ul, + ol, + blockquote { + padding-inline: 1rem; + } + + img { + max-width: 100%; + } +`; + +const MarkdownContent = ({ content }) => { + const [reactContent, setMarkdownSource] = useRemark(); + + useEffect(() => { + setMarkdownSource(content); + }, [content]); + + return {reactContent}; +}; + +const NotificationBody = ({ notification }) => { + const displayAsMarkdown = notification.content_type === "text/markdown"; + const formatted = formatMessage(notification); + if (displayAsMarkdown) { + return ; + } + return autolink(formatted); +}; + +const NotificationItem = (props) => { + const { t, i18n } = useTranslation(); + const { notification } = props; + const { attachment } = notification; + const date = formatShortDateTime(notification.time, i18n.language); + const otherTags = unmatchedTags(notification.tags); + const tags = otherTags.length > 0 ? otherTags.join(", ") : null; + const handleDelete = async () => { + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); + }; + const handleMarkRead = async () => { + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id); + }; + const handleCopy = (s) => { + navigator.clipboard.writeText(s); + props.onShowSnack(); + }; + const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000; + const hasAttachmentActions = attachment && !expired; + const hasClickAction = notification.click; + const hasUserActions = notification.actions && notification.actions.length > 0; + const showActions = hasAttachmentActions || hasClickAction || hasUserActions; + + return ( + + + + + + + + {notification.new === 1 && ( + + + + + + )} + + {date} + {[1, 2, 4, 5].includes(notification.priority) && ( + {t("notifications_priority_x", + )} + {notification.new === 1 && ( + + + + )} + + {notification.title && ( + + {formatTitle(notification)} + + )} + + + {maybeActionErrors(notification)} + + {attachment && } + {tags && ( + + {t("notifications_tags")}: {tags} + + )} + + {showActions && ( + + {hasAttachmentActions && ( + <> + + + + + + + + )} + {hasClickAction && ( + <> + + + + + + + + )} + {hasUserActions && } + + )} + + ); +}; + +const Attachment = (props) => { + const { t, i18n } = useTranslation(); + const { attachment } = props; + const expired = attachment.expires && attachment.expires < Date.now() / 1000; + const expires = attachment.expires && attachment.expires > Date.now() / 1000; + const displayableImage = !expired && isImage(attachment); + + // Unexpired image + if (displayableImage) { + return ; + } + + // Anything else: Show box + const infos = []; + if (attachment.size) { + infos.push(formatBytes(attachment.size)); + } + if (expires) { + infos.push( + t("notifications_attachment_link_expires", { + date: formatShortDateTime(attachment.expires, i18n.language), + }) + ); + } + if (expired) { + infos.push(t("notifications_attachment_link_expired")); + } + const maybeInfoText = + infos.length > 0 ? ( + <> +
+ {infos.join(", ")} + + ) : null; + + // If expired, just show infos without click target + if (expired) { + return ( + + + + {attachment.name} + {maybeInfoText} + + + ); + } + + // Not expired + return ( + + + + + {attachment.name} + {maybeInfoText} + + + + ); +}; + +const Image = (props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( + <> + setOpen(true)} + sx={{ + marginTop: 2, + borderRadius: "4px", + boxShadow: 2, + width: 1, + maxHeight: "400px", + objectFit: "cover", + cursor: "pointer", + }} + /> + setOpen(false)} BackdropComponent={LightboxBackdrop}> + + + + + + ); +}; + +const UserActions = (props) => ( + <> + {props.notification.actions.map((action) => ( + + ))} + +); + +const ACTION_PROGRESS_ONGOING = 1; +const ACTION_PROGRESS_SUCCESS = 2; +const ACTION_PROGRESS_FAILED = 3; + +const ACTION_LABEL_SUFFIX = { + [ACTION_PROGRESS_ONGOING]: " …", + [ACTION_PROGRESS_SUCCESS]: " ✔", + [ACTION_PROGRESS_FAILED]: " ❌", +}; + +const updateActionStatus = (notification, action, progress, error) => { + subscriptionManager.updateNotification({ + ...notification, + actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)), + }); +}; + +const performHttpAction = async (notification, action) => { + console.log(`[Notifications] Performing HTTP user action`, action); + try { + updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); + const response = await fetch(action.url, { + method: action.method ?? "POST", + headers: action.headers ?? {}, + // This must not null-coalesce to a non nullish value. Otherwise, the fetch API + // will reject it for "having a body" + body: action.body, + }); + console.log(`[Notifications] HTTP user action response`, response); + const success = response.status >= 200 && response.status <= 299; + if (success) { + updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); + } else { + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); + } + } catch (e) { + console.log(`[Notifications] HTTP action failed`, e); + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); + } +}; + +const UserAction = (props) => { + const { t } = useTranslation(); + const { notification } = props; + const { action } = props; + if (action.action === "broadcast") { + return ( + + + + + + ); + } + if (action.action === "view") { + return ( + + + + ); + } + if (action.action === "http") { + const method = action.method ?? "POST"; + const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); + return ( + + + + ); + } + return null; // Others +}; + +const NoNotifications = (props) => { + const { t } = useTranslation(); + const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_none_for_topic_title")} +
+ {t("notifications_none_for_topic_description")} + + {t("notifications_example")}:
+ + {'$ curl -d "Hi" '} + {topicShortUrlResolved} + +
+ + + +
+ ); +}; + +const NoNotificationsWithoutSubscription = (props) => { + const { t } = useTranslation(); + const subscription = props.subscriptions[0]; + const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_none_for_any_title")} +
+ {t("notifications_none_for_any_description")} + + {t("notifications_example")}:
+ + {'$ curl -d "Hi" '} + {topicShortUrlResolved} + +
+ + + +
+ ); +}; + +const NoSubscriptions = () => { + const { t } = useTranslation(); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_no_subscriptions_title")} +
+ + {t("notifications_no_subscriptions_description", { + linktext: t("nav_button_subscribe"), + })} + + + + +
+ ); +}; + +const ForMoreDetails = () => ( + , + docsLink: , + }} + /> +); + +const Loading = () => { + const { t } = useTranslation(); + return ( + + + +
+ {t("notifications_loading")} +
+
+ ); +}; diff --git a/web/src/components/PopupMenu.jsx b/web/src/components/PopupMenu.jsx new file mode 100644 index 00000000..89b20119 --- /dev/null +++ b/web/src/components/PopupMenu.jsx @@ -0,0 +1,48 @@ +import { Fade, Menu } from "@mui/material"; +import * as React from "react"; + +const PopupMenu = (props) => { + const horizontal = props.horizontal ?? "left"; + const arrow = horizontal === "right" ? { right: 19 } : { left: 19 }; + return ( + + {props.children} + + ); +}; + +export default PopupMenu; diff --git a/web/src/components/Pref.jsx b/web/src/components/Pref.jsx new file mode 100644 index 00000000..4da17381 --- /dev/null +++ b/web/src/components/Pref.jsx @@ -0,0 +1,60 @@ +import { styled } from "@mui/material"; +import * as React from "react"; + +export const PrefGroup = styled("div", { attrs: { role: "table" } })` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const PrefRow = styled("div")` + display: flex; + flex-direction: row; + + > div:first-of-type { + flex: 1 0 40%; + display: flex; + flex-direction: column; + justify-content: ${(props) => (props.alignTop ? "normal" : "center")}; + } + + > div:last-of-type { + flex: 1 0 calc(60% - 50px); + display: flex; + flex-direction: column; + justify-content: ${(props) => (props.alignTop ? "normal" : "center")}; + } + + @media (max-width: 1000px) { + flex-direction: column; + gap: 10px; + + > :div:first-of-type, + > :div:last-of-type { + flex: unset; + } + + > div:last-of-type { + .MuiFormControl-root { + margin: 0; + } + } + } +`; + +export const Pref = (props) => ( + +
+
+ {props.title} + {props.subtitle && ({props.subtitle})} +
+ {props.description && ( +
+ {props.description} +
+ )} +
+
{props.children}
+
+); diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js deleted file mode 100644 index 7d320e39..00000000 --- a/web/src/components/Preferences.js +++ /dev/null @@ -1,468 +0,0 @@ -import * as React from 'react'; -import {useEffect, useState} from 'react'; -import { - CardActions, - CardContent, - FormControl, - Select, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - useMediaQuery -} from "@mui/material"; -import Typography from "@mui/material/Typography"; -import prefs from "../app/Prefs"; -import {Paragraph} from "./styles"; -import EditIcon from '@mui/icons-material/Edit'; -import CloseIcon from "@mui/icons-material/Close"; -import IconButton from "@mui/material/IconButton"; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import Container from "@mui/material/Container"; -import TextField from "@mui/material/TextField"; -import MenuItem from "@mui/material/MenuItem"; -import Card from "@mui/material/Card"; -import Button from "@mui/material/Button"; -import {useLiveQuery} from "dexie-react-hooks"; -import theme from "./theme"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import DialogActions from "@mui/material/DialogActions"; -import userManager from "../app/UserManager"; -import {playSound, shuffle, sounds, validUrl} from "../app/utils"; -import {useTranslation} from "react-i18next"; - -const Preferences = () => { - return ( - - - - - - - - ); -}; - -const Notifications = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_notifications_title")} - - - - - - - - ); -}; - -const Sound = () => { - const { t } = useTranslation(); - const labelId = "prefSound"; - const sound = useLiveQuery(async () => prefs.sound()); - const handleChange = async (ev) => { - await prefs.setSound(ev.target.value); - } - if (!sound) { - return null; // While loading - } - let description; - if (sound === "none") { - description = t("prefs_notifications_sound_description_none"); - } else { - description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); - } - return ( - -
- - - - playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> - - -
-
- ) -}; - -const MinPriority = () => { - const { t } = useTranslation(); - const labelId = "prefMinPriority"; - const minPriority = useLiveQuery(async () => prefs.minPriority()); - const handleChange = async (ev) => { - await prefs.setMinPriority(ev.target.value); - } - if (!minPriority) { - return null; // While loading - } - const priorities = { - 1: t("priority_min"), - 2: t("priority_low"), - 3: t("priority_default"), - 4: t("priority_high"), - 5: t("priority_max") - } - let description; - if (minPriority === 1) { - description = t("prefs_notifications_min_priority_description_any"); - } else if (minPriority === 5) { - description = t("prefs_notifications_min_priority_description_max"); - } else { - description = t("prefs_notifications_min_priority_description_x_or_higher", { - number: minPriority, - name: priorities[minPriority] - }); - } - return ( - - - - - - ) -}; - -const DeleteAfter = () => { - const { t } = useTranslation(); - const labelId = "prefDeleteAfter"; - const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); - const handleChange = async (ev) => { - await prefs.setDeleteAfter(ev.target.value); - } - if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" - return null; // While loading - } - const description = (() => { - switch (deleteAfter) { - case 0: return t("prefs_notifications_delete_after_never_description"); - case 10800: return t("prefs_notifications_delete_after_three_hours_description"); - case 86400: return t("prefs_notifications_delete_after_one_day_description"); - case 604800: return t("prefs_notifications_delete_after_one_week_description"); - case 2592000: return t("prefs_notifications_delete_after_one_month_description"); - } - })(); - return ( - - - - - - ) -}; - -const PrefGroup = (props) => { - return ( -
- {props.children} -
- ) -}; - -const Pref = (props) => { - return ( -
-
-
{props.title}
- {props.description &&
{props.description}
} -
-
- {props.children} -
-
- ); -}; - -const Users = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const users = useLiveQuery(() => userManager.all()); - const handleAddClick = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; - const handleDialogCancel = () => { - setDialogOpen(false); - }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); - } catch (e) { - console.log(`[Preferences] Error adding user.`, e); - } - }; - return ( - - - - {t("prefs_users_title")} - - - {t("prefs_users_description")} - - {users?.length > 0 && } - - - - - - - ); -}; - -const UserTable = (props) => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const [dialogUser, setDialogUser] = useState(null); - const handleEditClick = (user) => { - setDialogKey(prev => prev+1); - setDialogUser(user); - setDialogOpen(true); - }; - const handleDialogCancel = () => { - setDialogOpen(false); - }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); - } catch (e) { - console.log(`[Preferences] Error updating user.`, e); - } - }; - const handleDeleteClick = async (user) => { - try { - await userManager.delete(user.baseUrl); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); - } catch (e) { - console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); - } - }; - return ( - - - - {t("prefs_users_table_user_header")} - {t("prefs_users_table_base_url_header")} - - - - - {props.users?.map(user => ( - - {user.username} - {user.baseUrl} - - handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> - - - - - ))} - - -
- ); -}; - -const UserDialog = (props) => { - const { t } = useTranslation(); - const [baseUrl, setBaseUrl] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const editMode = props.user !== null; - const addButtonEnabled = (() => { - if (editMode) { - return username.length > 0 && password.length > 0; - } - const baseUrlValid = validUrl(baseUrl); - const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); - return baseUrlValid - && !baseUrlExists - && username.length > 0 - && password.length > 0; - })(); - const handleSubmit = async () => { - props.onSubmit({ - baseUrl: baseUrl, - username: username, - password: password - }) - }; - useEffect(() => { - if (editMode) { - setBaseUrl(props.user.baseUrl); - setUsername(props.user.username); - setPassword(props.user.password); - } - }, [editMode, props.user]); - return ( - - {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} - - {!editMode && setBaseUrl(ev.target.value)} - type="url" - fullWidth - variant="standard" - />} - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - ); -}; - -const Appearance = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_appearance_title")} - - - - - - ); -}; - -const Language = () => { - const { t, i18n } = useTranslation(); - const labelId = "prefLanguage"; - const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); - const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); - const lang = i18n.language ?? "en"; - - // Remember: Flags are not languages. Don't put flags next to the language in the list. - // Languages names from: https://www.omniglot.com/language/names.htm - // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l - - return ( - - - - - - ) -}; - -export default Preferences; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx new file mode 100644 index 00000000..546ecbe3 --- /dev/null +++ b/web/src/components/Preferences.jsx @@ -0,0 +1,749 @@ +import * as React from "react"; +import { useContext, useEffect, useState } from "react"; +import { + Alert, + CardActions, + CardContent, + Chip, + FormControl, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + useMediaQuery, + Typography, + IconButton, + Container, + TextField, + MenuItem, + Card, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + useTheme, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import CloseIcon from "@mui/icons-material/Close"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import { useLiveQuery } from "dexie-react-hooks"; +import { useTranslation } from "react-i18next"; +import { Info } from "@mui/icons-material"; +import { useOutletContext } from "react-router-dom"; +import userManager from "../app/UserManager"; +import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, { Permission, Role } from "../app/AccountApi"; +import { Pref, PrefGroup } from "./Pref"; +import { AccountContext } from "./App"; +import { Paragraph } from "./styles"; +import prefs, { THEME } from "../app/Prefs"; +import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; +import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; +import { UnauthorizedError } from "../app/errors"; +import { subscribeTopic } from "./SubscribeDialog"; +import notifier from "../app/Notifier"; +import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; + +const maybeUpdateAccountSettings = async (payload) => { + if (!session.exists()) { + return; + } + try { + await accountApi.updateSettings(payload); + } catch (e) { + console.log(`[Preferences] Error updating account settings`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } +}; + +const Preferences = () => ( + + + + + + + + +); + +const Notifications = () => { + const { t } = useTranslation(); + const isLaunchedPWA = useIsLaunchedPWA(); + const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible()); + + return ( + + + {t("prefs_notifications_title")} + + + + + + {!isLaunchedPWA && pushPossible && } + + + ); +}; + +const Sound = () => { + const { t } = useTranslation(); + const labelId = "prefSound"; + const sound = useLiveQuery(async () => prefs.sound()); + const handleChange = async (ev) => { + await prefs.setSound(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + sound: ev.target.value, + }, + }); + }; + if (!sound) { + return null; // While loading + } + let description; + if (sound === "none") { + description = t("prefs_notifications_sound_description_none"); + } else { + description = t("prefs_notifications_sound_description_some", { + sound: sounds[sound].label, + }); + } + return ( + +
+ + + + playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> + + +
+
+ ); +}; + +const MinPriority = () => { + const { t } = useTranslation(); + const labelId = "prefMinPriority"; + const minPriority = useLiveQuery(async () => prefs.minPriority()); + const handleChange = async (ev) => { + await prefs.setMinPriority(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + min_priority: ev.target.value, + }, + }); + }; + if (!minPriority) { + return null; // While loading + } + const priorities = { + 1: t("priority_min"), + 2: t("priority_low"), + 3: t("priority_default"), + 4: t("priority_high"), + 5: t("priority_max"), + }; + let description; + if (minPriority === 1) { + description = t("prefs_notifications_min_priority_description_any"); + } else if (minPriority === 5) { + description = t("prefs_notifications_min_priority_description_max"); + } else { + description = t("prefs_notifications_min_priority_description_x_or_higher", { + number: minPriority, + name: priorities[minPriority], + }); + } + return ( + + + + + + ); +}; + +const DeleteAfter = () => { + const { t } = useTranslation(); + const labelId = "prefDeleteAfter"; + const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); + const handleChange = async (ev) => { + await prefs.setDeleteAfter(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + delete_after: ev.target.value, + }, + }); + }; + + if (deleteAfter === null || deleteAfter === undefined) { + // !deleteAfter will not work with "0" + return null; // While loading + } + + const description = (() => { + switch (deleteAfter) { + case 0: + return t("prefs_notifications_delete_after_never_description"); + case 10800: + return t("prefs_notifications_delete_after_three_hours_description"); + case 86400: + return t("prefs_notifications_delete_after_one_day_description"); + case 604800: + return t("prefs_notifications_delete_after_one_week_description"); + case 2592000: + return t("prefs_notifications_delete_after_one_month_description"); + default: + return ""; + } + })(); + + return ( + + + + + + ); +}; + +const Theme = () => { + const { t } = useTranslation(); + const labelId = "prefTheme"; + const theme = useLiveQuery(async () => prefs.theme()); + const handleChange = async (ev) => { + await prefs.setTheme(ev.target.value); + }; + + return ( + + + + + + ); +}; + +const WebPushEnabled = () => { + const { t } = useTranslation(); + const labelId = "prefWebPushEnabled"; + const enabled = useLiveQuery(async () => prefs.webPushEnabled()); + const handleChange = async (ev) => { + await prefs.setWebPushEnabled(ev.target.value); + }; + + return ( + + + + + + ); +}; + +const Users = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const users = useLiveQuery(() => userManager.all()); + const handleAddClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + const handleDialogCancel = () => { + setDialogOpen(false); + }; + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); + } catch (e) { + console.log(`[Preferences] Error adding user.`, e); + } + }; + return ( + + + + {t("prefs_users_title")} + + + {t("prefs_users_description")} + {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}} + + {users?.length > 0 && } + + + + + + + ); +}; + +const UserTable = (props) => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogUser, setDialogUser] = useState(null); + + const handleEditClick = (user) => { + setDialogKey((prev) => prev + 1); + setDialogUser(user); + setDialogOpen(true); + }; + + const handleDialogCancel = () => { + setDialogOpen(false); + }; + + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); + } catch (e) { + console.log(`[Preferences] Error updating user.`, e); + } + }; + + const handleDeleteClick = async (user) => { + try { + await userManager.delete(user.baseUrl); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); + } catch (e) { + console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); + } + }; + + return ( + + + + {t("prefs_users_table_user_header")} + {t("prefs_users_table_base_url_header")} + + + + + {props.users?.map((user) => ( + + + {user.username} + + {user.baseUrl} + + {(!session.exists() || user.baseUrl !== config.base_url) && ( + <> + handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> + + + handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> + + + + )} + {session.exists() && user.baseUrl === config.base_url && ( + + + + + + + + + + + )} + + + ))} + + +
+ ); +}; + +const UserDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [baseUrl, setBaseUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const editMode = props.user !== null; + const addButtonEnabled = (() => { + if (editMode) { + return username.length > 0 && password.length > 0; + } + const baseUrlValid = validUrl(baseUrl); + const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); + return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0; + })(); + const handleSubmit = async () => { + props.onSubmit({ + baseUrl, + username, + password, + }); + }; + useEffect(() => { + if (editMode) { + setBaseUrl(props.user.baseUrl); + setUsername(props.user.username); + setPassword(props.user.password); + } + }, [editMode, props.user]); + return ( + + {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} + + {!editMode && ( + setBaseUrl(ev.target.value)} + type="url" + fullWidth + variant="standard" + /> + )} + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + +const Appearance = () => { + const { t } = useTranslation(); + return ( + + + {t("prefs_appearance_title")} + + + + + + + ); +}; + +const Language = () => { + const { t, i18n } = useTranslation(); + const labelId = "prefLanguage"; + const lang = i18n.resolvedLanguage ?? "en"; + + // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. + // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. + const randomFlags = shuffle([ + "🇬🇧", + "🇺🇸", + "🇪🇸", + "🇫🇷", + "🇧🇬", + "🇨🇿", + "🇩🇪", + "🇵🇱", + "🇺🇦", + "🇨🇳", + "🇮🇹", + "🇭🇺", + "🇧🇷", + "🇳🇱", + "🇮🇩", + "🇯🇵", + "🇷🇺", + "🇹🇷", + ]).slice(0, 3); + const showFlags = !navigator.userAgent.includes("Windows"); + let title = t("prefs_appearance_language_title"); + if (showFlags) { + title += ` ${randomFlags.join(" ")}`; + } + + const handleChange = async (ev) => { + await i18n.changeLanguage(ev.target.value); + await maybeUpdateAccountSettings({ + language: ev.target.value, + }); + }; + + // Remember: Flags are not languages. Don't put flags next to the language in the list. + // Languages names from: https://www.omniglot.com/language/names.htm + // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l + + return ( + + + + + + ); +}; + +const Reservations = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + if (!config.enable_reservations || !session.exists() || !account) { + return <>; + } + const reservations = account.reservations || []; + const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; + + const handleAddClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + return ( + + + + {t("prefs_reservations_title")} + + {t("prefs_reservations_description")} + {reservations.length > 0 && } + {limitReached && {t("prefs_reservations_limit_reached")}} + + + + setDialogOpen(false)} + /> + + + ); +}; + +const ReservationsTable = (props) => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogReservation, setDialogReservation] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { subscriptions } = useOutletContext(); + const localSubscriptions = + subscriptions?.length > 0 + ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) + : {}; + + const handleEditClick = (reservation) => { + setDialogKey((prev) => prev + 1); + setDialogReservation(reservation); + setEditDialogOpen(true); + }; + + const handleDeleteClick = async (reservation) => { + setDialogKey((prev) => prev + 1); + setDialogReservation(reservation); + setDeleteDialogOpen(true); + }; + + const handleSubscribeClick = async (reservation) => { + await subscribeTopic(config.base_url, reservation.topic, {}); + }; + + return ( + + + + {t("prefs_reservations_table_topic_header")} + {t("prefs_reservations_table_access_header")} + + + + + {props.reservations.map((reservation) => ( + + + {reservation.topic} + + + {reservation.everyone === Permission.READ_WRITE && ( + <> + + {t("prefs_reservations_table_everyone_read_write")} + + )} + {reservation.everyone === Permission.READ_ONLY && ( + <> + + {t("prefs_reservations_table_everyone_read_only")} + + )} + {reservation.everyone === Permission.WRITE_ONLY && ( + <> + + {t("prefs_reservations_table_everyone_write_only")} + + )} + {reservation.everyone === Permission.DENY_ALL && ( + <> + + {t("prefs_reservations_table_everyone_deny_all")} + + )} + + + {!localSubscriptions[reservation.topic] && ( + + } + onClick={() => handleSubscribeClick(reservation)} + label={t("prefs_reservations_table_not_subscribed")} + color="primary" + variant="outlined" + /> + + )} + handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> + + + handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> + + + + + ))} + + setEditDialogOpen(false)} + /> + setDeleteDialogOpen(false)} + /> +
+ ); +}; + +export default Preferences; diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js deleted file mode 100644 index 13108239..00000000 --- a/web/src/components/PublishDialog.js +++ /dev/null @@ -1,725 +0,0 @@ -import * as React from 'react'; -import {useEffect, useRef, useState} from 'react'; -import {NotificationItem} from "./Notifications"; -import theme from "./theme"; -import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material"; -import TextField from "@mui/material/TextField"; -import priority1 from "../img/priority-1.svg"; -import priority2 from "../img/priority-2.svg"; -import priority3 from "../img/priority-3.svg"; -import priority4 from "../img/priority-4.svg"; -import priority5 from "../img/priority-5.svg"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import Button from "@mui/material/Button"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; -import {Close} from "@mui/icons-material"; -import MenuItem from "@mui/material/MenuItem"; -import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; -import Box from "@mui/material/Box"; -import AttachmentIcon from "./AttachmentIcon"; -import DialogFooter from "./DialogFooter"; -import api from "../app/Api"; -import userManager from "../app/UserManager"; -import EmojiPicker from "./EmojiPicker"; -import {Trans, useTranslation} from "react-i18next"; - -const PublishDialog = (props) => { - const { t } = useTranslation(); - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [message, setMessage] = useState(""); - const [messageFocused, setMessageFocused] = useState(true); - const [title, setTitle] = useState(""); - const [tags, setTags] = useState(""); - const [priority, setPriority] = useState(3); - const [clickUrl, setClickUrl] = useState(""); - const [attachUrl, setAttachUrl] = useState(""); - const [attachFile, setAttachFile] = useState(null); - const [filename, setFilename] = useState(""); - const [filenameEdited, setFilenameEdited] = useState(false); - const [email, setEmail] = useState(""); - const [delay, setDelay] = useState(""); - const [publishAnother, setPublishAnother] = useState(false); - - const [showTopicUrl, setShowTopicUrl] = useState(""); - const [showClickUrl, setShowClickUrl] = useState(false); - const [showAttachUrl, setShowAttachUrl] = useState(false); - const [showEmail, setShowEmail] = useState(false); - const [showDelay, setShowDelay] = useState(false); - - const showAttachFile = !!attachFile && !showAttachUrl; - const attachFileInput = useRef(); - const [attachFileError, setAttachFileError] = useState(""); - - const [activeRequest, setActiveRequest] = useState(null); - const [status, setStatus] = useState(""); - const disabled = !!activeRequest; - - const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); - - const [dropZone, setDropZone] = useState(false); - const [sendButtonEnabled, setSendButtonEnabled] = useState(true); - - const open = !!props.openMode; - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - - useEffect(() => { - window.addEventListener('dragenter', () => { - props.onDragEnter(); - setDropZone(true); - }); - }, []); - - useEffect(() => { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(!props.baseUrl || !props.topic); - setMessageFocused(!!props.topic); // Focus message only if topic is set - }, [props.baseUrl, props.topic]); - - useEffect(() => { - const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; - setSendButtonEnabled(valid); - }, [baseUrl, topic, attachFileError]); - - useEffect(() => { - setMessage(props.message); - }, [props.message]); - - const handleSubmit = async () => { - const url = new URL(topicUrl(baseUrl, topic)); - if (title.trim()) { - url.searchParams.append("title", title.trim()); - } - if (tags.trim()) { - url.searchParams.append("tags", tags.trim()); - } - if (priority && priority !== 3) { - url.searchParams.append("priority", priority.toString()); - } - if (clickUrl.trim()) { - url.searchParams.append("click", clickUrl.trim()); - } - if (attachUrl.trim()) { - url.searchParams.append("attach", attachUrl.trim()); - } - if (filename.trim()) { - url.searchParams.append("filename", filename.trim()); - } - if (email.trim()) { - url.searchParams.append("email", email.trim()); - } - if (delay.trim()) { - url.searchParams.append("delay", delay.trim()); - } - if (attachFile && message.trim()) { - url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); - } - const body = (attachFile) ? attachFile : message; - try { - const user = await userManager.get(baseUrl); - const headers = maybeWithBasicAuth({}, user); - const progressFn = (ev) => { - if (ev.loaded > 0 && ev.total > 0) { - setStatus(t("publish_dialog_progress_uploading_detail", { - loaded: formatBytes(ev.loaded), - total: formatBytes(ev.total), - percent: Math.round(ev.loaded * 100.0 / ev.total) - })); - } else { - setStatus(t("publish_dialog_progress_uploading")); - } - }; - const request = api.publishXHR(url, body, headers, progressFn); - setActiveRequest(request); - await request; - if (!publishAnother) { - props.onClose(); - } else { - setStatus(t("publish_dialog_message_published")); - setActiveRequest(null); - } - } catch (e) { - setStatus({e}); - setActiveRequest(null); - } - }; - - const checkAttachmentLimits = async (file) => { - try { - const stats = await api.userStats(baseUrl); - const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0; - const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0; - const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; - const quotaReached = remainingBytes > 0 && file.size > remainingBytes; - if (fileSizeLimitReached && quotaReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", { - fileSizeLimit: formatBytes(fileSizeLimit), - remainingBytes: formatBytes(remainingBytes) - })); - } else if (fileSizeLimitReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) })); - } else if (quotaReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) })); - } - setAttachFileError(""); - } catch (e) { - console.log(`[SendDialog] Retrieving attachment limits failed`, e); - setAttachFileError(""); // Reset error (rely on server-side checking) - } - }; - - const handleAttachFileClick = () => { - attachFileInput.current.click(); - }; - - const handleAttachFileChanged = async (ev) => { - await updateAttachFile(ev.target.files[0]); - }; - - const handleAttachFileDrop = async (ev) => { - ev.preventDefault(); - setDropZone(false); - await updateAttachFile(ev.dataTransfer.files[0]); - }; - - const updateAttachFile = async (file) => { - setAttachFile(file); - setFilename(file.name); - props.onResetOpenMode(); - await checkAttachmentLimits(file); - }; - - const handleAttachFileDragLeave = () => { - setDropZone(false); - if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { - props.onClose(); // Only close dialog if it was not open before dragging file in - } - }; - - const handleEmojiClick = (ev) => { - setEmojiPickerAnchorEl(ev.currentTarget); - }; - - const handleEmojiPick = (emoji) => { - setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji); - }; - - const handleEmojiClose = () => { - setEmojiPickerAnchorEl(null); - }; - - const priorities = { - 1: { label: t("publish_dialog_priority_min"), file: priority1 }, - 2: { label: t("publish_dialog_priority_low"), file: priority2 }, - 3: { label: t("publish_dialog_priority_default"), file: priority3 }, - 4: { label: t("publish_dialog_priority_high"), file: priority4 }, - 5: { label: t("publish_dialog_priority_max"), file: priority5 } - }; - - return ( - <> - {dropZone && - } - - {(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")} - - {dropZone && } - {showTopicUrl && - { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(false); - }}> - setBaseUrl(ev.target.value)} - disabled={disabled} - type="url" - variant="standard" - sx={{flexGrow: 1, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_base_url_label") - }} - /> - setTopic(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - autoFocus={!messageFocused} - sx={{flexGrow: 1}} - inputProps={{ - "aria-label": t("publish_dialog_topic_label") - }} - /> - - } - setTitle(ev.target.value)} - disabled={disabled} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_title_label") - }} - /> - setMessage(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - rows={5} - autoFocus={messageFocused} - fullWidth - multiline - inputProps={{ - "aria-label": t("publish_dialog_message_label") - }} - /> -
- - - - - setTags(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - sx={{flexGrow: 1, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_tags_label") - }} - /> - - - - -
- {showClickUrl && - { - setClickUrl(""); - setShowClickUrl(false); - }}> - setClickUrl(ev.target.value)} - disabled={disabled} - type="url" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_click_label") - }} - /> - - } - {showEmail && - { - setEmail(""); - setShowEmail(false); - }}> - setEmail(ev.target.value)} - disabled={disabled} - type="email" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_email_label") - }} - /> - - } - {showAttachUrl && - { - setAttachUrl(""); - setFilename(""); - setFilenameEdited(false); - setShowAttachUrl(false); - }}> - { - const url = ev.target.value; - setAttachUrl(url); - if (!filenameEdited) { - try { - const u = new URL(url); - const parts = u.pathname.split("/"); - if (parts.length > 0) { - setFilename(parts[parts.length-1]); - } - } catch (e) { - // Do nothing - } - } - }} - disabled={disabled} - type="url" - variant="standard" - sx={{flexGrow: 5, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_attach_label") - }} - /> - { - setFilename(ev.target.value); - setFilenameEdited(true); - }} - disabled={disabled} - type="text" - variant="standard" - sx={{flexGrow: 1}} - inputProps={{ - "aria-label": t("publish_dialog_filename_label") - }} - /> - - } - - {showAttachFile && setFilename(f)} - onClose={() => { - setAttachFile(null); - setAttachFileError(""); - setFilename(""); - }} - />} - {showDelay && - { - setDelay(""); - setShowDelay(false); - }}> - setDelay(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_delay_label") - }} - /> - - } - - {t("publish_dialog_other_features")} - -
- {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} -
- - - }} - /> - -
- - {activeRequest && } - {!activeRequest && - <> - setPublishAnother(ev.target.checked)} - inputProps={{ - "aria-label": t("publish_dialog_checkbox_publish_another") - }} /> - } /> - - - - } - -
- - ); -}; - -const Row = (props) => { - return ( -
- {props.children} -
- ); -}; - -const ClosableRow = (props) => { - const closable = (props.hasOwnProperty("closable")) ? props.closable : true; - return ( - - {props.children} - {closable && - - - - } - - ); -}; - -const DialogIconButton = (props) => { - const sx = props.sx || {}; - return ( - - {props.children} - - ); -}; - -const AttachmentBox = (props) => { - const { t } = useTranslation(); - const file = props.file; - return ( - <> - - {t("publish_dialog_attached_file_title")} - - - - - props.onChangeFilename(ev.target.value)} - disabled={props.disabled} - /> -
- - {formatBytes(file.size)} - {props.error && - - {" "}({props.error}) - - } - -
- - - -
- - ); -}; - -const ExpandingTextField = (props) => { - const invisibleFieldRef = useRef(); - const [textWidth, setTextWidth] = useState(props.minWidth); - const determineTextWidth = () => { - const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); - if (!boundingRect) { - return props.minWidth; - } - return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth; - }; - useEffect(() => { - setTextWidth(determineTextWidth() + 5); - }, [props.value]); - return ( - <> - - {props.value} - - - - ) -}; - -const DropArea = (props) => { - const allowDrag = (ev) => { - // This is where we could disallow certain files to be dragged in. - // For now we allow all files. - - ev.dataTransfer.dropEffect = 'copy'; - ev.preventDefault(); - }; - - return ( - - ); -}; - -const DropBox = () => { - const { t } = useTranslation(); - return ( - - - {t("publish_dialog_drop_file_here")} - - - ); -} - -PublishDialog.OPEN_MODE_DEFAULT = "default"; -PublishDialog.OPEN_MODE_DRAG = "drag"; - -export default PublishDialog; diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx new file mode 100644 index 00000000..f18eec8d --- /dev/null +++ b/web/src/components/PublishDialog.jsx @@ -0,0 +1,934 @@ +import * as React from "react"; +import { useContext, useEffect, useRef, useState } from "react"; +import { + Checkbox, + Chip, + FormControl, + FormControlLabel, + InputLabel, + Link, + Select, + Tooltip, + useMediaQuery, + TextField, + Dialog, + DialogTitle, + DialogContent, + Button, + Typography, + IconButton, + MenuItem, + Box, + useTheme, +} from "@mui/material"; +import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; +import { Close } from "@mui/icons-material"; +import { Trans, useTranslation } from "react-i18next"; +import priority1 from "../img/priority-1.svg"; +import priority2 from "../img/priority-2.svg"; +import priority3 from "../img/priority-3.svg"; +import priority4 from "../img/priority-4.svg"; +import priority5 from "../img/priority-5.svg"; +import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils"; +import AttachmentIcon from "./AttachmentIcon"; +import DialogFooter from "./DialogFooter"; +import api from "../app/Api"; +import userManager from "../app/UserManager"; +import EmojiPicker from "./EmojiPicker"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi from "../app/AccountApi"; +import { UnauthorizedError } from "../app/errors"; +import { AccountContext } from "./App"; + +const PublishDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [message, setMessage] = useState(""); + const [messageFocused, setMessageFocused] = useState(true); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [priority, setPriority] = useState(3); + const [clickUrl, setClickUrl] = useState(""); + const [attachUrl, setAttachUrl] = useState(""); + const [attachFile, setAttachFile] = useState(null); + const [filename, setFilename] = useState(""); + const [filenameEdited, setFilenameEdited] = useState(false); + const [email, setEmail] = useState(""); + const [call, setCall] = useState(""); + const [delay, setDelay] = useState(""); + const [publishAnother, setPublishAnother] = useState(false); + const [markdownEnabled, setMarkdownEnabled] = useState(false); + + const [showTopicUrl, setShowTopicUrl] = useState(""); + const [showClickUrl, setShowClickUrl] = useState(false); + const [showAttachUrl, setShowAttachUrl] = useState(false); + const [showEmail, setShowEmail] = useState(false); + const [showCall, setShowCall] = useState(false); + const [showDelay, setShowDelay] = useState(false); + + const showAttachFile = !!attachFile && !showAttachUrl; + const attachFileInput = useRef(); + const [attachFileError, setAttachFileError] = useState(""); + + const [activeRequest, setActiveRequest] = useState(null); + const [status, setStatus] = useState(""); + const disabled = !!activeRequest; + + const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); + + const [dropZone, setDropZone] = useState(false); + const [sendButtonEnabled, setSendButtonEnabled] = useState(true); + + const open = !!props.openMode; + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + useEffect(() => { + window.addEventListener("dragenter", () => { + props.onDragEnter(); + setDropZone(true); + }); + }, []); + + useEffect(() => { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(!props.baseUrl || !props.topic); + setMessageFocused(!!props.topic); // Focus message only if topic is set + }, [props.baseUrl, props.topic]); + + useEffect(() => { + const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; + setSendButtonEnabled(valid); + }, [baseUrl, topic, attachFileError]); + + useEffect(() => { + setMessage(props.message); + }, [props.message]); + + const updateBaseUrl = (newVal) => { + if (validUrl(newVal)) { + setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?:// + } else { + setBaseUrl(newVal); + } + }; + + const handleSubmit = async () => { + const url = new URL(topicUrl(baseUrl, topic)); + if (title.trim()) { + url.searchParams.append("title", title.trim()); + } + if (tags.trim()) { + url.searchParams.append("tags", tags.trim()); + } + if (priority && priority !== 3) { + url.searchParams.append("priority", priority.toString()); + } + if (clickUrl.trim()) { + url.searchParams.append("click", clickUrl.trim()); + } + if (attachUrl.trim()) { + url.searchParams.append("attach", attachUrl.trim()); + } + if (filename.trim()) { + url.searchParams.append("filename", filename.trim()); + } + if (email.trim()) { + url.searchParams.append("email", email.trim()); + } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } + if (delay.trim()) { + url.searchParams.append("delay", delay.trim()); + } + if (attachFile && message.trim()) { + url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); + } + if (markdownEnabled) { + url.searchParams.append("markdown", "true"); + } + + const body = attachFile || message; + try { + const user = await userManager.get(baseUrl); + const headers = maybeWithAuth({}, user); + const progressFn = (ev) => { + if (ev.loaded > 0 && ev.total > 0) { + setStatus( + t("publish_dialog_progress_uploading_detail", { + loaded: formatBytes(ev.loaded), + total: formatBytes(ev.total), + percent: Math.round((ev.loaded * 100.0) / ev.total), + }) + ); + } else { + setStatus(t("publish_dialog_progress_uploading")); + } + }; + const request = api.publishXHR(url, body, headers, progressFn); + setActiveRequest(request); + await request; + if (!publishAnother) { + props.onClose(); + } else { + setStatus(t("publish_dialog_message_published")); + setActiveRequest(null); + } + } catch (e) { + setStatus({e}); + setActiveRequest(null); + } + }; + + const checkAttachmentLimits = async (file) => { + try { + const apiAccount = await accountApi.get(); + const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0; + const remainingBytes = apiAccount.stats.attachment_total_size_remaining; + const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; + const quotaReached = remainingBytes > 0 && file.size > remainingBytes; + if (fileSizeLimitReached && quotaReached) { + setAttachFileError( + t("publish_dialog_attachment_limits_file_and_quota_reached", { + fileSizeLimit: formatBytes(fileSizeLimit), + remainingBytes: formatBytes(remainingBytes), + }) + ); + } else if (fileSizeLimitReached) { + setAttachFileError( + t("publish_dialog_attachment_limits_file_reached", { + fileSizeLimit: formatBytes(fileSizeLimit), + }) + ); + } else if (quotaReached) { + setAttachFileError( + t("publish_dialog_attachment_limits_quota_reached", { + remainingBytes: formatBytes(remainingBytes), + }) + ); + } else { + setAttachFileError(""); + } + } catch (e) { + console.log(`[PublishDialog] Retrieving attachment limits failed`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setAttachFileError(""); // Reset error (rely on server-side checking) + } + } + }; + + const handleAttachFileClick = () => { + attachFileInput.current.click(); + }; + + const updateAttachFile = async (file) => { + setAttachFile(file); + setFilename(file.name); + props.onResetOpenMode(); + await checkAttachmentLimits(file); + }; + + const handleAttachFileChanged = async (ev) => { + await updateAttachFile(ev.target.files[0]); + }; + + const handleAttachFileDrop = async (ev) => { + ev.preventDefault(); + setDropZone(false); + await updateAttachFile(ev.dataTransfer.files[0]); + }; + + const handleAttachFileDragLeave = () => { + setDropZone(false); + if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { + props.onClose(); // Only close dialog if it was not open before dragging file in + } + }; + + const handleEmojiClick = (ev) => { + setEmojiPickerAnchorEl(ev.currentTarget); + }; + + const handleEmojiPick = (emoji) => { + setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji)); + }; + + const handleEmojiClose = () => { + setEmojiPickerAnchorEl(null); + }; + + const priorities = { + 1: { label: t("publish_dialog_priority_min"), file: priority1 }, + 2: { label: t("publish_dialog_priority_low"), file: priority2 }, + 3: { label: t("publish_dialog_priority_default"), file: priority3 }, + 4: { label: t("publish_dialog_priority_high"), file: priority4 }, + 5: { label: t("publish_dialog_priority_max"), file: priority5 }, + }; + + return ( + <> + {dropZone && } + + + {baseUrl && topic + ? t("publish_dialog_title_topic", { + topic: topicShortUrl(baseUrl, topic), + }) + : t("publish_dialog_title_no_topic")} + + + {dropZone && } + {showTopicUrl && ( + { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(false); + }} + > + updateBaseUrl(ev.target.value)} + disabled={disabled} + type="url" + variant="standard" + sx={{ flexGrow: 1, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_base_url_label"), + }} + /> + setTopic(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + autoFocus={!messageFocused} + sx={{ flexGrow: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_topic_label"), + }} + /> + + )} + setTitle(ev.target.value)} + disabled={disabled} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_title_label"), + }} + /> + setMessage(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + rows={5} + autoFocus={messageFocused} + fullWidth + multiline + inputProps={{ + "aria-label": t("publish_dialog_message_label"), + }} + /> + setMarkdownEnabled(ev.target.checked)} + inputProps={{ + "aria-label": t("publish_dialog_checkbox_markdown"), + }} + /> + } + /> +
+ + + + + setTags(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + sx={{ flexGrow: 1, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_tags_label"), + }} + /> + + + + +
+ {showClickUrl && ( + { + setClickUrl(""); + setShowClickUrl(false); + }} + > + setClickUrl(ev.target.value)} + disabled={disabled} + type="url" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_click_label"), + }} + /> + + )} + {showEmail && ( + { + setEmail(""); + setShowEmail(false); + }} + > + setEmail(ev.target.value)} + disabled={disabled} + type="email" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_email_label"), + }} + /> + + )} + {showCall && ( + { + setCall(""); + setShowCall(false); + }} + > + + + + + + )} + {showAttachUrl && ( + { + setAttachUrl(""); + setFilename(""); + setFilenameEdited(false); + setShowAttachUrl(false); + }} + > + { + const url = ev.target.value; + setAttachUrl(url); + if (!filenameEdited) { + try { + const u = new URL(url); + const parts = u.pathname.split("/"); + if (parts.length > 0) { + setFilename(parts[parts.length - 1]); + } + } catch (e) { + // Do nothing + } + } + }} + disabled={disabled} + type="url" + variant="standard" + sx={{ flexGrow: 5, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_attach_label"), + }} + /> + { + setFilename(ev.target.value); + setFilenameEdited(true); + }} + disabled={disabled} + type="text" + variant="standard" + sx={{ flexGrow: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_filename_label"), + }} + /> + + )} + + {showAttachFile && ( + setFilename(f)} + onClose={() => { + setAttachFile(null); + setAttachFileError(""); + setFilename(""); + }} + /> + )} + {showDelay && ( + { + setDelay(""); + setShowDelay(false); + }} + > + setDelay(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_delay_label"), + }} + /> + + )} + + {t("publish_dialog_other_features")} + +
+ {!showClickUrl && ( + setShowClickUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showEmail && ( + setShowEmail(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {account?.phone_numbers?.length > 0 && !showCall && ( + { + setShowCall(true); + setCall(account.phone_numbers[0]); + }} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showAttachUrl && !showAttachFile && ( + setShowAttachUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showAttachFile && !showAttachUrl && ( + handleAttachFileClick()} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showDelay && ( + setShowDelay(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showTopicUrl && ( + setShowTopicUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {account && !account?.phone_numbers && ( + + + + + + )} +
+ + , + }} + /> + +
+ + {activeRequest && } + {!activeRequest && ( + <> + setPublishAnother(ev.target.checked)} + inputProps={{ + "aria-label": t("publish_dialog_checkbox_publish_another"), + }} + /> + } + /> + + + + )} + +
+ + ); +}; + +const Row = (props) => ( +
+ {props.children} +
+); + +const ClosableRow = (props) => { + const closable = props.closable !== undefined ? props.closable : true; + return ( + + {props.children} + {closable && ( + + + + )} + + ); +}; + +const DialogIconButton = (props) => { + const sx = props.sx || {}; + return ( + + {props.children} + + ); +}; + +const AttachmentBox = (props) => { + const { t } = useTranslation(); + const { file } = props; + return ( + <> + + {t("publish_dialog_attached_file_title")} + + + + + props.onChangeFilename(ev.target.value)} + disabled={props.disabled} + /> +
+ + {formatBytes(file.size)} + {props.error && ( + + {" "} + ({props.error}) + + )} + +
+ + + +
+ + ); +}; + +const ExpandingTextField = (props) => { + const theme = useTheme(); + const invisibleFieldRef = useRef(); + const [textWidth, setTextWidth] = useState(props.minWidth); + const determineTextWidth = () => { + const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); + if (!boundingRect) { + return props.minWidth; + } + return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth; + }; + useEffect(() => { + setTextWidth(determineTextWidth() + 5); + }, [props.value]); + return ( + <> + + {props.value} + + + + ); +}; + +const DropArea = (props) => { + const allowDrag = (ev) => { + // This is where we could disallow certain files to be dragged in. + // For now we allow all files. + + // eslint-disable-next-line no-param-reassign + ev.dataTransfer.dropEffect = "copy"; + ev.preventDefault(); + }; + + return ( + + ); +}; + +const DropBox = () => { + const { t } = useTranslation(); + return ( + + + {t("publish_dialog_drop_file_here")} + + + ); +}; + +PublishDialog.OPEN_MODE_DEFAULT = "default"; +PublishDialog.OPEN_MODE_DRAG = "drag"; + +export default PublishDialog; diff --git a/web/src/components/RTLCacheProvider.jsx b/web/src/components/RTLCacheProvider.jsx new file mode 100644 index 00000000..a85fced6 --- /dev/null +++ b/web/src/components/RTLCacheProvider.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +import rtlPlugin from "stylis-plugin-rtl"; +import { CacheProvider } from "@emotion/react"; +import createCache from "@emotion/cache"; +import { prefixer } from "stylis"; +import { useTranslation } from "react-i18next"; + +// https://mui.com/material-ui/guides/right-to-left + +const cacheRtl = createCache({ + key: "muirtl", + stylisPlugins: [prefixer, rtlPlugin], +}); + +const RTLCacheProvider = ({ children }) => { + const { i18n } = useTranslation(); + + return i18n.dir() === "rtl" ? {children} : children; +}; + +export default RTLCacheProvider; diff --git a/web/src/components/ReserveDialogs.jsx b/web/src/components/ReserveDialogs.jsx new file mode 100644 index 00000000..7eb893cd --- /dev/null +++ b/web/src/components/ReserveDialogs.jsx @@ -0,0 +1,202 @@ +import * as React from "react"; +import { useState } from "react"; +import { + Button, + TextField, + Dialog, + DialogContent, + DialogContentText, + DialogTitle, + Alert, + FormControl, + Select, + useMediaQuery, + MenuItem, + ListItemIcon, + ListItemText, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Check, DeleteForever } from "@mui/icons-material"; +import { validTopic } from "../app/utils"; +import DialogFooter from "./DialogFooter"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, { Permission } from "../app/AccountApi"; +import ReserveTopicSelect from "./ReserveTopicSelect"; +import { TopicReservedError, UnauthorizedError } from "../app/errors"; + +export const ReserveAddDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [topic, setTopic] = useState(props.topic || ""); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const allowTopicEdit = !props.topic; + const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0; + const submitButtonEnabled = validTopic(topic) && !alreadyReserved; + + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(topic, everyone); + console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); + } catch (e) { + console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_add")} + + {t("prefs_reservations_dialog_description")} + {allowTopicEdit && ( + setTopic(ev.target.value)} + type="url" + fullWidth + variant="standard" + /> + )} + + + + + + + + ); +}; + +export const ReserveEditDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(props.reservation.topic, everyone); + console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); + } catch (e) { + console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_edit")} + + {t("prefs_reservations_dialog_description")} + + + + + + + + ); +}; + +export const ReserveDeleteDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [deleteMessages, setDeleteMessages] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleSubmit = async () => { + try { + await accountApi.deleteReservation(props.topic, deleteMessages); + console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); + } catch (e) { + console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_delete")} + + {t("reservation_delete_dialog_description")} + + + + {!deleteMessages && ( + + {t("reservation_delete_dialog_action_keep_description")} + + )} + {deleteMessages && ( + + {t("reservation_delete_dialog_action_delete_description")} + + )} + + + + + + + ); +}; diff --git a/web/src/components/ReserveIcons.jsx b/web/src/components/ReserveIcons.jsx new file mode 100644 index 00000000..95f6f47c --- /dev/null +++ b/web/src/components/ReserveIcons.jsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Lock, Public } from "@mui/icons-material"; +import { Box } from "@mui/material"; + +export const PermissionReadWrite = React.forwardRef((props, ref) => ); + +export const PermissionDenyAll = React.forwardRef((props, ref) => ); + +export const PermissionRead = React.forwardRef((props, ref) => ); + +export const PermissionWrite = React.forwardRef((props, ref) => ); + +const PermissionInternal = React.forwardRef((props, ref) => { + const size = props.size ?? "medium"; + const Icon = props.icon; + return ( + + + {props.text && ( + + {props.text} + + )} + + ); +}); diff --git a/web/src/components/ReserveTopicSelect.jsx b/web/src/components/ReserveTopicSelect.jsx new file mode 100644 index 00000000..39ae5df2 --- /dev/null +++ b/web/src/components/ReserveTopicSelect.jsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; +import { Permission } from "../app/AccountApi"; + +const ReserveTopicSelect = (props) => { + const { t } = useTranslation(); + const sx = props.sx || {}; + return ( + + + + ); +}; + +export default ReserveTopicSelect; diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx new file mode 100644 index 00000000..7da54c49 --- /dev/null +++ b/web/src/components/Signup.jsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { useState } from "react"; +import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "@mui/material"; +import { NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import session from "../app/Session"; +import routes from "./routes"; +import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; + +const Signup = () => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + await accountApi.create(user.username, user.password); + const token = await accountApi.login(user); + console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); + await session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Signup] Signup for user ${user.username} failed`, e); + if (e instanceof UserExistsError) { + setError(t("signup_error_username_taken", { username: e.username })); + } else if (e instanceof AccountCreateLimitReachedError) { + setError(t("signup_error_creation_limit_reached")); + } else { + setError(e.message); + } + } + }; + + if (!config.enable_signup) { + return ( + + {t("signup_disabled")} + + ); + } + + return ( + + {t("signup_title")} + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + setConfirm(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowConfirm(!showConfirm)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showConfirm ? : } + + + ), + }} + /> + + {error && ( + + + {error} + + )} + + {config.enable_login && ( + + + {t("signup_already_have_account")} + + + )} + + ); +}; + +export default Signup; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js deleted file mode 100644 index 9ab5a08f..00000000 --- a/web/src/components/SubscribeDialog.js +++ /dev/null @@ -1,212 +0,0 @@ -import * as React from 'react'; -import {useState} from 'react'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; -import theme from "./theme"; -import api from "../app/Api"; -import {topicUrl, validTopic, validUrl} from "../app/utils"; -import userManager from "../app/UserManager"; -import subscriptionManager from "../app/SubscriptionManager"; -import poller from "../app/Poller"; -import DialogFooter from "./DialogFooter"; -import {useTranslation} from "react-i18next"; - -const publicBaseUrl = "https://ntfy.sh"; - -const SubscribeDialog = (props) => { - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [showLoginPage, setShowLoginPage] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const handleSuccess = async () => { - const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; - const subscription = await subscriptionManager.add(actualBaseUrl, topic); - poller.pollInBackground(subscription); // Dangle! - props.onSuccess(subscription); - } - return ( - - {!showLoginPage && setShowLoginPage(true)} - onSuccess={handleSuccess} - />} - {showLoginPage && setShowLoginPage(false)} - onSuccess={handleSuccess} - />} - - ); -}; - -const SubscribePage = (props) => { - const { t } = useTranslation(); - const [anotherServerVisible, setAnotherServerVisible] = useState(false); - const [errorText, setErrorText] = useState(""); - const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin; - const topic = props.topic; - const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); - const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) - .filter(s => s !== window.location.origin); - const handleSubscribe = async () => { - const user = await userManager.get(baseUrl); // May be undefined - const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); - const success = await api.auth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - if (user) { - setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); - return; - } else { - props.onNeedsLogin(); - return; - } - } - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - props.onSuccess(); - }; - const handleUseAnotherChanged = (e) => { - props.setBaseUrl(""); - setAnotherServerVisible(e.target.checked); - }; - const subscribeButtonEnabled = (() => { - if (anotherServerVisible) { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); - return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; - } else { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(window.location.origin, topic)); - return validTopic(topic) && !isExistingTopicUrl; - } - })(); - return ( - <> - {t("subscribe_dialog_subscribe_title")} - - - {t("subscribe_dialog_subscribe_description")} - - props.setTopic(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - maxLength: 64, - "aria-label": t("subscribe_dialog_subscribe_topic_placeholder") - }} - /> - - } - label={t("subscribe_dialog_subscribe_use_another_label")} /> - {anotherServerVisible && props.setBaseUrl(newVal)} - renderInput={ (params) => - - } - />} - - - - - - - ); -}; - -const LoginPage = (props) => { - const { t } = useTranslation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [errorText, setErrorText] = useState(""); - const baseUrl = (props.baseUrl) ? props.baseUrl : window.location.origin; - const topic = props.topic; - const handleLogin = async () => { - const user = {baseUrl, username, password}; - const success = await api.auth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); - return; - } - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - await userManager.save(user); - props.onSuccess(); - }; - return ( - <> - {t("subscribe_dialog_login_title")} - - - {t("subscribe_dialog_login_description")} - - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_username_label") - }} - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_password_label") - }} - /> - - - - - - - ); -}; - -export default SubscribeDialog; diff --git a/web/src/components/SubscribeDialog.jsx b/web/src/components/SubscribeDialog.jsx new file mode 100644 index 00000000..f7a24f5e --- /dev/null +++ b/web/src/components/SubscribeDialog.jsx @@ -0,0 +1,332 @@ +import * as React from "react"; +import { useContext, useState } from "react"; +import { + Button, + TextField, + Dialog, + DialogContent, + DialogContentText, + DialogTitle, + Autocomplete, + FormControlLabel, + FormGroup, + useMediaQuery, + Switch, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useLiveQuery } from "dexie-react-hooks"; +import api from "../app/Api"; +import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; +import userManager from "../app/UserManager"; +import subscriptionManager from "../app/SubscriptionManager"; +import poller from "../app/Poller"; +import DialogFooter from "./DialogFooter"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, { Permission, Role } from "../app/AccountApi"; +import ReserveTopicSelect from "./ReserveTopicSelect"; +import { AccountContext } from "./App"; +import { TopicReservedError, UnauthorizedError } from "../app/errors"; +import { ReserveLimitChip } from "./SubscriptionPopup"; +import prefs from "../app/Prefs"; + +const publicBaseUrl = "https://ntfy.sh"; + +export const subscribeTopic = async (baseUrl, topic, opts) => { + const subscription = await subscriptionManager.add(baseUrl, topic, opts); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, topic); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } + } + return subscription; +}; + +const SubscribeDialog = (props) => { + const theme = useTheme(); + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [showLoginPage, setShowLoginPage] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleSuccess = async () => { + console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); + const actualBaseUrl = baseUrl || config.base_url; + const subscription = await subscribeTopic(actualBaseUrl, topic, {}); + poller.pollInBackground(subscription); // Dangle! + props.onSuccess(subscription); + }; + + return ( + + {!showLoginPage && ( + setShowLoginPage(true)} + onSuccess={handleSuccess} + /> + )} + {showLoginPage && setShowLoginPage(false)} onSuccess={handleSuccess} />} + + ); +}; + +const SubscribePage = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [reserveTopicVisible, setReserveTopicVisible] = useState(false); + const [anotherServerVisible, setAnotherServerVisible] = useState(false); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; + const { topic } = props; + const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic)); + const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter( + (s) => s !== config.base_url + ); + const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); + const reserveTopicEnabled = + session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); + + const webPushEnabled = useLiveQuery(() => prefs.webPushEnabled()); + + const handleSubscribe = async () => { + const user = await userManager.get(baseUrl); // May be undefined + const username = user ? user.username : t("subscribe_dialog_error_user_anonymous"); + + // Check read access to topic + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); + if (user) { + setError( + t("subscribe_dialog_error_user_not_authorized", { + username, + }) + ); + return; + } + props.onNeedsLogin(); + return; + } + + // Reserve topic (if requested) + if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { + console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); + try { + await accountApi.upsertReservation(topic, everyone); + } catch (e) { + console.log(`[SubscribeDialog] Error reserving topic`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } + } + } + + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); + props.onSuccess(); + }; + + const handleUseAnotherChanged = (e) => { + props.setBaseUrl(""); + setAnotherServerVisible(e.target.checked); + }; + + const subscribeButtonEnabled = (() => { + if (anotherServerVisible) { + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); + return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; + } + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); + return validTopic(topic) && !isExistingTopicUrl; + })(); + + const updateBaseUrl = (ev, newVal) => { + if (validUrl(newVal)) { + props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?:// + } else { + props.setBaseUrl(newVal); + } + }; + + return ( + <> + {t("subscribe_dialog_subscribe_title")} + + {t("subscribe_dialog_subscribe_description")} +
+ props.setTopic(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"), + }} + /> + +
+ {showReserveTopicCheckbox && ( + + setReserveTopicVisible(ev.target.checked)} + inputProps={{ + "aria-label": t("reserve_dialog_checkbox_label"), + }} + /> + } + label={ + <> + {t("reserve_dialog_checkbox_label")} + + + } + /> + {reserveTopicVisible && } + + )} + {!reserveTopicVisible && ( + + + } + label={t("subscribe_dialog_subscribe_use_another_label")} + /> + {anotherServerVisible && ( + ( + <> + + {webPushEnabled && ( +
+ {t("subscribe_dialog_subscribe_use_another_background_info")} +
+ )} + + )} + /> + )} +
+ )} +
+ + + + + + ); +}; + +const LoginPage = (props) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const baseUrl = props.baseUrl ? props.baseUrl : config.base_url; + const { topic } = props; + + const handleLogin = async () => { + const user = { baseUrl, username, password }; + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); + setError(t("subscribe_dialog_error_user_not_authorized", { username })); + return; + } + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); + await userManager.save(user); + props.onSuccess(); + }; + + return ( + <> + {t("subscribe_dialog_login_title")} + + {t("subscribe_dialog_login_description")} + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_username_label"), + }} + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_password_label"), + }} + /> + + + + + + + ); +}; + +export default SubscribeDialog; diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx new file mode 100644 index 00000000..1a6a689c --- /dev/null +++ b/web/src/components/SubscriptionPopup.jsx @@ -0,0 +1,396 @@ +import * as React from "react"; +import { useContext, useState } from "react"; +import { + Button, + TextField, + Dialog, + DialogContent, + DialogContentText, + DialogTitle, + Chip, + InputAdornment, + Portal, + Snackbar, + useMediaQuery, + MenuItem, + IconButton, + ListItemIcon, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { + Clear, + ClearAll, + Edit, + EnhancedEncryption, + Lock, + LockOpen, + Notifications, + NotificationsOff, + RemoveCircle, + Send, +} from "@mui/icons-material"; +import subscriptionManager from "../app/SubscriptionManager"; +import DialogFooter from "./DialogFooter"; +import accountApi, { Role } from "../app/AccountApi"; +import session from "../app/Session"; +import routes from "./routes"; +import PopupMenu from "./PopupMenu"; +import { formatShortDateTime, shuffle } from "../app/utils"; +import api from "../app/Api"; +import { AccountContext } from "./App"; +import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; +import { UnauthorizedError } from "../app/errors"; + +export const SubscriptionPopup = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const navigate = useNavigate(); + const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); + const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); + const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); + const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); + const [showPublishError, setShowPublishError] = useState(false); + const { subscription } = props; + const placement = props.placement ?? "left"; + const reservations = account?.reservations || []; + + const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; + const showReservationAddDisabled = + !showReservationAdd && + config.enable_reservations && + !subscription?.reservation && + (config.enable_payments || account?.stats.reservations_remaining === 0); + const showReservationEdit = config.enable_reservations && !!subscription?.reservation; + const showReservationDelete = config.enable_reservations && !!subscription?.reservation; + + const handleChangeDisplayName = async () => { + setDisplayNameDialogOpen(true); + }; + + const handleReserveAdd = async () => { + setReserveAddDialogOpen(true); + }; + + const handleReserveEdit = async () => { + setReserveEditDialogOpen(true); + }; + + const handleReserveDelete = async () => { + setReserveDeleteDialogOpen(true); + }; + + const handleSendTestMessage = async () => { + const { baseUrl, topic } = props.subscription; + const tags = shuffle([ + "grinning", + "octopus", + "upside_down_face", + "palm_tree", + "maple_leaf", + "apple", + "skull", + "warning", + "jack_o_lantern", + "de-server-1", + "backups", + "cron-script", + "script-error", + "phils-automation", + "mouse", + "go-rocks", + "hi-ben", + ]).slice(0, Math.round(Math.random() * 4)); + const priority = shuffle([1, 2, 3, 4, 5])[0]; + const title = shuffle([ + "", + "", + "", // Higher chance of no title + "Oh my, another test message?", + "Titles are optional, did you know that?", + "ntfy is open source, and will always be free. Cool, right?", + "I don't really like apples", + "My favorite TV show is The Wire. You should watch it!", + "You can attach files and URLs to messages too", + "You can delay messages up to 3 days", + ])[0]; + const nowSeconds = Math.round(Date.now() / 1000); + const message = shuffle([ + `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( + nowSeconds, + "en-US" + )} right now. Is that early or late?`, + `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, + `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, + `Alright then, it's ${formatShortDateTime( + nowSeconds, + "en-US" + )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, + `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, + `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, + `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, + ])[0]; + try { + await api.publish(baseUrl, topic, message, { + title, + priority, + tags, + }); + } catch (e) { + console.log(`[SubscriptionPopup] Error publishing message`, e); + setShowPublishError(true); + } + }; + + const handleClearAll = async () => { + console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); + await subscriptionManager.deleteNotifications(props.subscription.id); + }; + + const handleSetMutedUntil = async (mutedUntil) => { + await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); + }; + + const handleUnsubscribe = async () => { + console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); + await subscriptionManager.remove(props.subscription); + if (session.exists() && !subscription.internal) { + try { + await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); + } catch (e) { + console.log(`[SubscriptionPopup] Error unsubscribing`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } + } + const newSelected = await subscriptionManager.first(); // May be undefined + if (newSelected && !newSelected.internal) { + navigate(routes.forSubscription(newSelected)); + } else { + navigate(routes.app); + } + }; + + return ( + <> + + + + + + {t("action_bar_change_display_name")} + + {showReservationAdd && ( + + + + + {t("action_bar_reservation_add")} + + )} + {showReservationAddDisabled && ( + + + + + {t("action_bar_reservation_add")} + + + )} + {showReservationEdit && ( + + + + + {t("action_bar_reservation_edit")} + + )} + {showReservationDelete && ( + + + + + {t("action_bar_reservation_delete")} + + )} + + + + + {t("action_bar_send_test_notification")} + + + + + + {t("action_bar_clear_notifications")} + + {!!subscription.mutedUntil && ( + handleSetMutedUntil(0)}> + + + + {t("action_bar_unmute_notifications")} + + )} + {!subscription.mutedUntil && ( + handleSetMutedUntil(1)}> + + + + {t("action_bar_mute_notifications")} + + )} + + + + + {t("action_bar_unsubscribe")} + + + + setShowPublishError(false)} + message={t("message_bar_error_publishing")} + /> + setDisplayNameDialogOpen(false)} /> + {showReservationAdd && ( + setReserveAddDialogOpen(false)} + /> + )} + {showReservationEdit && ( + setReserveEditDialogOpen(false)} + /> + )} + {showReservationDelete && ( + setReserveDeleteDialogOpen(false)} + /> + )} + + + ); +}; + +const DisplayNameDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { subscription } = props; + const [error, setError] = useState(""); + const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleSave = async () => { + await subscriptionManager.setDisplayName(subscription.id, displayName); + if (session.exists() && !subscription.internal) { + try { + console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); + await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); + } catch (e) { + console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + } + props.onClose(); + }; + + return ( + + {t("display_name_dialog_title")} + + {t("display_name_dialog_description")} + setDisplayName(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("display_name_dialog_placeholder"), + }} + InputProps={{ + endAdornment: ( + + setDisplayName("")} edge="end"> + + + + ), + }} + /> + + + + + + + ); +}; + +export const ReserveLimitChip = () => { + const { account } = useContext(AccountContext); + if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { + return <>; + } + if (config.enable_payments) { + return account?.limits.reservations > 0 ? : ; + } + if (account) { + return ; + } + return <>; +}; + +const LimitReachedChip = () => { + const { t } = useTranslation(); + return ( + + ); +}; + +export const ProChip = () => ( + +); diff --git a/web/src/components/UpgradeDialog.jsx b/web/src/components/UpgradeDialog.jsx new file mode 100644 index 00000000..712c47ec --- /dev/null +++ b/web/src/components/UpgradeDialog.jsx @@ -0,0 +1,436 @@ +import * as React from "react"; +import { useContext, useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogTitle, + Alert, + CardActionArea, + CardContent, + Chip, + Link, + ListItem, + Switch, + useMediaQuery, + Button, + Card, + Typography, + List, + ListItemIcon, + ListItemText, + Box, + DialogContentText, + DialogActions, + useTheme, +} from "@mui/material"; +import { Trans, useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { NavLink } from "react-router-dom"; +import { UnauthorizedError } from "../app/errors"; +import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils"; +import { AccountContext } from "./App"; +import routes from "./routes"; +import session from "../app/Session"; +import accountApi, { SubscriptionInterval } from "../app/AccountApi"; + +const Feature = (props) => {props.children}; + +const NoFeature = (props) => {props.children}; + +const FeatureItem = (props) => ( + + + {props.feature && } + {!props.feature && } + + {props.children}} /> + +); + +const Action = { + REDIRECT_SIGNUP: 1, + CREATE_SUBSCRIPTION: 2, + UPDATE_SUBSCRIPTION: 3, + CANCEL_SUBSCRIPTION: 4, +}; + +const Banner = { + CANCEL_WARNING: 1, + PRORATION_INFO: 2, + RESERVATIONS_WARNING: 3, +}; + +const UpgradeDialog = (props) => { + const theme = useTheme(); + const { t, i18n } = useTranslation(); + const { account } = useContext(AccountContext); // May be undefined! + const [error, setError] = useState(""); + const [tiers, setTiers] = useState(null); + const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); + const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined + const [loading, setLoading] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + useEffect(() => { + const fetchTiers = async () => { + setTiers(await accountApi.billingTiers()); + }; + fetchTiers(); // Dangle + }, []); + + if (!tiers) { + return <>; + } + + const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier }))); + const newTier = tiersMap[newTierCode]; // May be undefined + const currentTier = account?.tier; // May be undefined + const currentInterval = account?.billing?.interval; // May be undefined + const currentTierCode = currentTier?.code; // May be undefined + + // Figure out buttons, labels and the submit action + let submitAction; + let submitButtonLabel; + let banner; + if (!account) { + submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); + submitAction = Action.REDIRECT_SIGNUP; + banner = null; + } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = null; + banner = currentTierCode ? Banner.PRORATION_INFO : null; + } else if (!currentTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); + submitAction = Action.CREATE_SUBSCRIPTION; + banner = null; + } else if (!newTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); + submitAction = Action.CANCEL_SUBSCRIPTION; + banner = Banner.CANCEL_WARNING; + } else { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = Action.UPDATE_SUBSCRIPTION; + banner = Banner.PRORATION_INFO; + } + + // Exceptional conditions + if (loading) { + submitAction = null; + } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { + submitAction = null; + banner = Banner.RESERVATIONS_WARNING; + } + + const handleSubmit = async () => { + if (submitAction === Action.REDIRECT_SIGNUP) { + window.location.href = routes.signup; + return; + } + try { + setLoading(true); + if (submitAction === Action.CREATE_SUBSCRIPTION) { + const response = await accountApi.createBillingSubscription(newTierCode, interval); + window.location.href = response.redirect_url; + } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { + await accountApi.updateBillingSubscription(newTierCode, interval); + } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { + await accountApi.deleteBillingSubscription(); + } + props.onCancel(); + } catch (e) { + console.log(`[UpgradeDialog] Error changing billing subscription`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setLoading(false); + } + }; + + // Figure out discount + let discount = 0; + let upto = false; + if (newTier?.prices) { + discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100); + } else { + let n = 0; + for (const tier of tiers) { + if (tier.prices) { + const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100); + if (tierDiscount > discount) { + discount = tierDiscount; + n += 1; + } + } + } + upto = n > 1; + } + + return ( + + +
+
{t("account_upgrade_dialog_title")}
+
+ + {t("account_upgrade_dialog_interval_monthly")} + + setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} + /> + + {t("account_upgrade_dialog_interval_yearly")} + + {discount > 0 && ( + + )} +
+
+
+ +
+ {tiers.map((tier) => ( + setNewTierCode(tier.code)} // tier.code may be undefined! + /> + ))} +
+ {banner === Banner.CANCEL_WARNING && ( + + + + )} + {banner === Banner.PRORATION_INFO && ( + + + + )} + {banner === Banner.RESERVATIONS_WARNING && ( + + , + }} + /> + + )} +
+ + + {config.billing_contact.indexOf("@") !== -1 && ( + <> + , + }} + />{" "} + + )} + {config.billing_contact.match(`^http?s://`) && ( + <> + , + }} + />{" "} + + )} + {error} + + + + + + +
+ ); +}; + +const TierCard = (props) => { + const { t } = useTranslation(); + const { tier } = props; + + let cardStyle; + let labelStyle; + let labelText; + if (props.selected) { + cardStyle = { background: "#eee", border: "3px solid #338574" }; + labelStyle = { background: "#338574", color: "white" }; + labelText = t("account_upgrade_dialog_tier_selected_label"); + } else if (props.current) { + cardStyle = { border: "3px solid #eee" }; + labelStyle = { background: "#eee", color: "black" }; + labelText = t("account_upgrade_dialog_tier_current_label"); + } else { + cardStyle = { border: "3px solid transparent" }; + } + + let monthlyPrice; + if (!tier.prices) { + monthlyPrice = 0; + } else if (props.interval === SubscriptionInterval.YEAR) { + monthlyPrice = tier.prices.year / 12; + } else if (props.interval === SubscriptionInterval.MONTH) { + monthlyPrice = tier.prices.month; + } + + return ( + + + + + {labelStyle && ( +
+ {labelText} +
+ )} + + {tier.name || t("account_basics_tier_free")} + +
+ + {formatPrice(monthlyPrice)} + + {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}} +
+ + {tier.limits.reservations > 0 && ( + + {t("account_upgrade_dialog_tier_features_reservations", { + reservations: tier.limits.reservations, + count: tier.limits.reservations, + })} + + )} + + {t("account_upgrade_dialog_tier_features_messages", { + messages: formatNumber(tier.limits.messages), + count: tier.limits.messages, + })} + + + {t("account_upgrade_dialog_tier_features_emails", { + emails: formatNumber(tier.limits.emails), + count: tier.limits.emails, + })} + + {tier.limits.calls > 0 && ( + + {t("account_upgrade_dialog_tier_features_calls", { + calls: formatNumber(tier.limits.calls), + count: tier.limits.calls, + })} + + )} + + {t("account_upgrade_dialog_tier_features_attachment_file_size", { + filesize: formatBytes(tier.limits.attachment_file_size, 0), + })} + + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} + + {tier.prices && props.interval === SubscriptionInterval.MONTH && ( + + {t("account_upgrade_dialog_tier_price_billed_monthly", { + price: formatPrice(tier.prices.month * 12), + })} + + )} + {tier.prices && props.interval === SubscriptionInterval.YEAR && ( + + {t("account_upgrade_dialog_tier_price_billed_yearly", { + price: formatPrice(tier.prices.year), + save: formatPrice(tier.prices.month * 12 - tier.prices.year), + })} + + )} +
+
+
+
+ ); +}; + +export default UpgradeDialog; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 3714a9d0..519d4c6a 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -1,44 +1,102 @@ -import {useNavigate, useParams} from "react-router-dom"; -import {useEffect, useState} from "react"; +import { useParams } from "react-router-dom"; +import { useEffect, useMemo, useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; import subscriptionManager from "../app/SubscriptionManager"; -import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; -import notifier from "../app/Notifier"; +import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; import routes from "./routes"; import connectionManager from "../app/ConnectionManager"; import poller from "../app/Poller"; import pruner from "../app/Pruner"; +import session from "../app/Session"; +import accountApi from "../app/AccountApi"; +import { UnauthorizedError } from "../app/errors"; +import notifier from "../app/Notifier"; +import prefs from "../app/Prefs"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection * state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead * to the connection being re-established). + * + * When Web Push is enabled, we do not need to connect to our home server via WebSocket, since notifications + * will be delivered via Web Push. However, we still need to connect to other servers via WebSocket, or for internal + * topics, such as sync topics (st_...). */ -export const useConnectionListeners = (subscriptions, users) => { - const navigate = useNavigate(); +export const useConnectionListeners = (account, subscriptions, users, webPushTopics) => { + const wsSubscriptions = useMemo( + () => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []), + // wsSubscriptions should stay stable unless the list of subscription IDs changes. Without the memo, the connection + // listener calls a refresh for no reason. This isn't a problem due to the makeConnectionId, but it triggers an + // unnecessary recomputation for every received message. + [JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })] + ); - useEffect(() => { - const handleNotification = async (subscriptionId, notification) => { - const added = await subscriptionManager.addNotification(subscriptionId, notification); - if (added) { - const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); - await notifier.notify(subscriptionId, notification, defaultClickAction) - } - }; - connectionManager.registerStateListener(subscriptionManager.updateState); - connectionManager.registerNotificationListener(handleNotification); - return () => { - connectionManager.resetStateListener(); - connectionManager.resetNotificationListener(); - } - }, - // We have to disable dep checking for "navigate". This is fine, it never changes. - // eslint-disable-next-line - [] - ); + // Register listeners for incoming messages, and connection state changes + useEffect( + () => { + const handleInternalMessage = async (message) => { + console.log(`[ConnectionListener] Received message on sync topic`, message.message); + try { + const data = JSON.parse(message.message); + if (data.event === "sync") { + console.log(`[ConnectionListener] Triggering account sync`); + await accountApi.sync(); + } else { + console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); + } + } catch (e) { + console.log(`[ConnectionListener] Error parsing sync topic message`, e); + } + }; - useEffect(() => { - connectionManager.refresh(subscriptions, users); // Dangle - }, [subscriptions, users]); + const handleNotification = async (subscriptionId, notification) => { + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + await subscriptionManager.notify(subscriptionId, notification); + } + }; + + const handleMessage = async (subscriptionId, message) => { + const subscription = await subscriptionManager.get(subscriptionId); + + // Race condition: sometimes the subscription is already unsubscribed from account + // sync before the message is handled + if (!subscription) { + return; + } + + if (subscription.internal) { + await handleInternalMessage(message); + } else { + await handleNotification(subscriptionId, message); + } + }; + + connectionManager.registerStateListener((id, state) => subscriptionManager.updateState(id, state)); + connectionManager.registerMessageListener(handleMessage); + + return () => { + connectionManager.resetStateListener(); + connectionManager.resetMessageListener(); + }; + }, + // We have to disable dep checking for "navigate". This is fine, it never changes. + + [] + ); + + // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic + useEffect(() => { + if (!account || !account.sync_topic) { + return; + } + subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle! + }, [account]); + + // When subscriptions or users change, refresh the connections + useEffect(() => { + connectionManager.refresh(wsSubscriptions, users); // Dangle + }, [wsSubscriptions, users]); }; /** @@ -46,25 +104,160 @@ export const useConnectionListeners = (subscriptions, users) => { * This will only be run once after the initial page load. */ export const useAutoSubscribe = (subscriptions, selected) => { - const [hasRun, setHasRun] = useState(false); - const params = useParams(); + const [hasRun, setHasRun] = useState(false); + const params = useParams(); - useEffect(() => { - const loaded = subscriptions !== null && subscriptions !== undefined; - if (!loaded || hasRun) { - return; + useEffect(() => { + const loaded = subscriptions !== null && subscriptions !== undefined; + if (!loaded || hasRun) { + return; + } + setHasRun(true); + const eligible = params.topic && !selected && !disallowedTopic(params.topic); + if (eligible) { + const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url; + console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); + (async () => { + const subscription = await subscriptionManager.add(baseUrl, params.topic); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, params.topic); + } catch (e) { + console.log(`[Hooks] Auto-subscribing failed`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } } - setHasRun(true); - const eligible = params.topic && !selected && !disallowedTopic(params.topic); - if (eligible) { - const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin; - console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); - (async () => { - const subscription = await subscriptionManager.add(baseUrl, params.topic); - poller.pollInBackground(subscription); // Dangle! - })(); - } - }, [params, subscriptions, selected, hasRun]); + poller.pollInBackground(subscription); // Dangle! + })(); + } + }, [params, subscriptions, selected, hasRun]); +}; + +const webPushBroadcastChannel = new BroadcastChannel("web-push-broadcast"); + +/** + * Hook to return a value that's refreshed when the notification permission changes + */ +export const useNotificationPermissionListener = (query) => { + const [result, setResult] = useState(query()); + + useEffect(() => { + const handler = () => { + setResult(query()); + }; + + if ("permissions" in navigator) { + navigator.permissions.query({ name: "notifications" }).then((permission) => { + permission.addEventListener("change", handler); + + return () => { + permission.removeEventListener("change", handler); + }; + }); + } + }, []); + + return result; +}; + +/** + * Updates the Web Push subscriptions when the list of topics changes, + * as well as plays a sound when a new broadcast message is received from + * the service worker, since the service worker cannot play sounds. + */ +const useWebPushListener = (topics) => { + const [prevUpdate, setPrevUpdate] = useState(); + const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible()); + + useEffect(() => { + const nextUpdate = JSON.stringify({ topics, pushPossible }); + if (topics === undefined || nextUpdate === prevUpdate) { + return; + } + + (async () => { + try { + console.log("[useWebPushListener] Refreshing web push subscriptions", topics); + await subscriptionManager.updateWebPushSubscriptions(topics); + setPrevUpdate(nextUpdate); + } catch (e) { + console.error("[useWebPushListener] Error refreshing web push subscriptions", e); + } + })(); + }, [topics, pushPossible, prevUpdate]); + + useEffect(() => { + const onMessage = () => { + notifier.playSound(); // Service Worker cannot play sound, so we do it here! + }; + + webPushBroadcastChannel.addEventListener("message", onMessage); + + return () => { + webPushBroadcastChannel.removeEventListener("message", onMessage); + }; + }); +}; + +/** + * Hook to return a list of Web Push enabled topics using a live query. This hook will return an empty list if + * permissions are not granted, or if the browser does not support Web Push. Notification permissions are acted upon + * automatically. + */ +export const useWebPushTopics = () => { + const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible()); + + const topics = useLiveQuery( + async () => subscriptionManager.webPushTopics(pushPossible), + // invalidate (reload) query when these values change + [pushPossible] + ); + + useWebPushListener(topics); + + return topics; +}; + +const matchMedia = window.matchMedia("(display-mode: standalone)"); +const isIOSStandalone = window.navigator.standalone === true; + +/* + * Watches the "display-mode" to detect if the app is running as a standalone app (PWA). + */ +export const useIsLaunchedPWA = () => { + const [isStandalone, setIsStandalone] = useState(matchMedia.matches); + + useEffect(() => { + if (isIOSStandalone) { + return () => {}; // No need to listen for events on iOS + } + const handler = (evt) => { + console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`); + setIsStandalone(evt.matches); + }; + matchMedia.addEventListener("change", handler); + return () => { + matchMedia.removeEventListener("change", handler); + }; + }, []); + + return isIOSStandalone || isStandalone; +}; + +/** + * Watches the result of `useIsLaunchedPWA` and enables "Web Push" if it is. + */ +export const useStandaloneWebPushAutoSubscribe = () => { + const isLaunchedPWA = useIsLaunchedPWA(); + + useEffect(() => { + if (isLaunchedPWA) { + console.log(`[useStandaloneWebPushAutoSubscribe] Turning on web push automatically`); + prefs.setWebPushEnabled(true); // Dangle! + } + }, [isLaunchedPWA]); }; /** @@ -72,9 +265,39 @@ export const useAutoSubscribe = (subscriptions, selected) => { * and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186. */ + +const startWorkers = () => { + poller.startWorker(); + pruner.startWorker(); + accountApi.startWorker(); +}; + +const stopWorkers = () => { + poller.stopWorker(); + pruner.stopWorker(); + accountApi.stopWorker(); +}; + export const useBackgroundProcesses = () => { - useEffect(() => { - poller.startWorker(); - pruner.startWorker(); - }, []); -} + useStandaloneWebPushAutoSubscribe(); + + useEffect(() => { + console.log("[useBackgroundProcesses] mounting"); + startWorkers(); + + return () => { + console.log("[useBackgroundProcesses] unloading"); + stopWorkers(); + }; + }, []); +}; + +export const useAccountListener = (setAccount) => { + useEffect(() => { + accountApi.registerListener(setAccount); + accountApi.sync(); // Dangle + return () => { + accountApi.resetListener(); + }; + }, []); +}; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 7a7a7857..17e0eac6 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -1,17 +1,20 @@ import config from "../app/config"; -import {shortUrl} from "../app/utils"; +import { shortUrl } from "../app/utils"; const routes = { - root: config.appRoot, - settings: "/settings", - subscription: "/:topic", - subscriptionExternal: "/:baseUrl/:topic", - forSubscription: (subscription) => { - if (subscription.baseUrl !== window.location.origin) { - return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; - } - return `/${subscription.topic}`; + login: "/login", + signup: "/signup", + app: config.app_root, + account: "/account", + settings: "/settings", + subscription: "/:topic", + subscriptionExternal: "/:baseUrl/:topic", + forSubscription: (subscription) => { + if (subscription.baseUrl !== config.base_url) { + return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } + return `/${subscription.topic}`; + }, }; export default routes; diff --git a/web/src/components/styles.js b/web/src/components/styles.js index d6127941..db0690bc 100644 --- a/web/src/components/styles.js +++ b/web/src/components/styles.js @@ -1,22 +1,19 @@ -import Typography from "@mui/material/Typography"; -import theme from "./theme"; -import Container from "@mui/material/Container"; -import {Backdrop, styled} from "@mui/material"; +import { Typography, Container, Backdrop, styled } from "@mui/material"; export const Paragraph = styled(Typography)({ paddingTop: 8, paddingBottom: 8, }); -export const VerticallyCenteredContainer = styled(Container)({ - display: 'flex', +export const VerticallyCenteredContainer = styled(Container)(({ theme }) => ({ + display: "flex", flexGrow: 1, - flexDirection: 'column', - justifyContent: 'center', - alignContent: 'center', - color: theme.palette.text.primary -}); + flexDirection: "column", + justifyContent: "center", + alignContent: "center", + color: theme.palette.text.primary, +})); export const LightboxBackdrop = styled(Backdrop)({ - backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5 + backgroundColor: "rgba(0, 0, 0, 0.8)", // was: 0.5 }); diff --git a/web/src/components/theme.js b/web/src/components/theme.js index 3fdafae8..64217eee 100644 --- a/web/src/components/theme.js +++ b/web/src/components/theme.js @@ -1,36 +1,74 @@ -import { red } from '@mui/material/colors'; -import { createTheme } from '@mui/material/styles'; - -const theme = createTheme({ - palette: { - primary: { - main: '#338574', - }, - secondary: { - main: '#6cead0', - }, - error: { - main: red.A400, - }, - }, +/** @type {import("@mui/material").ThemeOptions} */ +const baseThemeOptions = { components: { MuiListItemIcon: { styleOverrides: { root: { - minWidth: '36px', + minWidth: "36px", }, }, }, MuiCardContent: { styleOverrides: { root: { - ':last-child': { - paddingBottom: '16px' - } - } - } - } + ":last-child": { + paddingBottom: "16px", + }, + }, + }, + }, }, -}); +}; -export default theme; +// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/colors.xml + +/** @type {import("@mui/material").ThemeOptions} */ +export const lightTheme = { + ...baseThemeOptions, + components: { + ...baseThemeOptions.components, + }, + palette: { + mode: "light", + primary: { + main: "#338574", + }, + secondary: { + main: "#6cead0", + }, + error: { + main: "#c30000", + }, + }, +}; + +/** @type {import("@mui/material").ThemeOptions} */ +export const darkTheme = { + ...baseThemeOptions, + components: { + ...baseThemeOptions.components, + MuiSnackbarContent: { + styleOverrides: { + root: { + color: "#000", + backgroundColor: "#aeaeae", + }, + }, + }, + }, + palette: { + mode: "dark", + background: { + paper: "#1b2124", + }, + primary: { + main: "#65b5a3", + }, + secondary: { + main: "#6cead0", + }, + error: { + main: "#fe4d2e", + }, + }, +}; diff --git a/web/src/img/ntfy-filled.svg b/web/src/img/ntfy-filled.svg new file mode 100644 index 00000000..a9c07fc3 --- /dev/null +++ b/web/src/img/ntfy-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/index.js b/web/src/index.js deleted file mode 100644 index 659bcb8f..00000000 --- a/web/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as React from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './components/App'; - -const root = createRoot(document.querySelector('#root')); -root.render(); diff --git a/web/src/index.jsx b/web/src/index.jsx new file mode 100644 index 00000000..1a123a8a --- /dev/null +++ b/web/src/index.jsx @@ -0,0 +1,9 @@ +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./components/App"; +import registerSW from "./registerSW"; + +registerSW(); + +const root = createRoot(document.querySelector("#root")); +root.render(); diff --git a/web/src/registerSW.js b/web/src/registerSW.js new file mode 100644 index 00000000..adef4746 --- /dev/null +++ b/web/src/registerSW.js @@ -0,0 +1,31 @@ +// eslint-disable-next-line import/no-unresolved +import { registerSW as viteRegisterSW } from "virtual:pwa-register"; + +// fetch new sw every hour, i.e. update app every hour while running +const intervalMS = 60 * 60 * 1000; + +// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html +const registerSW = () => + viteRegisterSW({ + onRegisteredSW(swUrl, registration) { + if (!registration) { + return; + } + + setInterval(async () => { + if (registration.installing || navigator?.onLine === false) return; + + const resp = await fetch(swUrl, { + cache: "no-store", + headers: { + cache: "no-store", + "cache-control": "no-cache", + }, + }); + + if (resp?.status === 200) await registration.update(); + }, intervalMS); + }, + }); + +export default registerSW; diff --git a/web/src/sounds/pop-swoosh.mp3 b/web/src/sounds/pop-swoosh.mp3 index 007ce4bd..3d5bf476 100644 Binary files a/web/src/sounds/pop-swoosh.mp3 and b/web/src/sounds/pop-swoosh.mp3 differ diff --git a/web/src/sounds/pop.mp3 b/web/src/sounds/pop.mp3 index 3d5bf476..007ce4bd 100644 Binary files a/web/src/sounds/pop.mp3 and b/web/src/sounds/pop.mp3 differ diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 00000000..a4fd5a31 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,60 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; + +export default defineConfig(({ mode }) => ({ + build: { + outDir: "build", + assetsDir: "static/media", + sourcemap: true, + }, + server: { + port: 3000, + }, + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + // see registerSW.js imported by index.jsx + injectRegister: null, + strategies: "injectManifest", + devOptions: { + enabled: true, + /* when using generateSW the PWA plugin will switch to classic */ + type: "module", + navigateFallback: "index.html", + }, + injectManifest: { + globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"], + globIgnores: ["config.js"], + manifestTransforms: [ + (entries) => ({ + manifest: entries.map((entry) => + // this matches the build step in the Makefile. + // since ntfy needs the ability to serve another page on /index.html, + // it's renamed and served from server.go as app.html as well. + entry.url === "index.html" + ? { + ...entry, + url: "app.html", + } + : entry + ), + }), + ], + }, + // The actual prod manifest is served from the go server, see server.go handleWebManifest. + manifest: mode === "development" && { + theme_color: "#317f6f", + icons: [ + { + src: "/static/images/pwa-192x192.png", + sizes: "192x192", + type: "image/png", + }, + ], + }, + }), + ], +}));