1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-29 12:30:11 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
97506c0bcd
build(deps): bump postcss from 8.4.24 to 8.4.31
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.24 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.24...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-05 11:38:10 +00:00
193 changed files with 7839 additions and 15666 deletions

178
.drone.yml Normal file
View File

@ -0,0 +1,178 @@
---
name: jfa-go
kind: pipeline
type: docker
steps:
- name: fetch
image: docker:git
commands:
- git fetch --tags
- name: release
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
GITHUB_TOKEN:
from_secret: github_token
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sL https://git.io/goreleaser > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
- wget https://builds.hrfee.pw/upload.py -P ../
- pip3 install requests
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- bash -c 'python3 ../upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
trigger:
event:
- tag
---
name: docker-buildx
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
event:
- tag
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-git
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
- name: ssh_key2
path: /id_rsa2
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
- wget https://builds.hrfee.pw/upload.py
- pip3 install requests
- bash -c 'sftp -i /id_rsa2 -o StrictHostKeyChecking=no root@161.97.102.153:/mnt/redoc <<< $"put docs/swagger.json jfa-go.json"'
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go"'
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go-tray"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
JFA_GO_SNAPSHOT: y
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
- name: ssh_key2
host:
path: /root/.ssh/docker-build
trigger:
branch:
- main
- go1.16
event:
exclude:
- pull_request
---
name: docker-buildx-unstable
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
branch:
- main
event:
exclude:
- pull_request
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-pr
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
trigger:
event:
include:
- pull_request

2
.gitignore vendored
View File

@ -25,5 +25,3 @@ scripts/langmover/lang
scripts/langmover/lang2 scripts/langmover/lang2
scripts/langmover/out scripts/langmover/out
tinyproxy.conf tinyproxy.conf
static/banner.svg
start.sh

View File

@ -1,5 +1,3 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: jfa-go project_name: jfa-go
release: release:
github: github:
@ -8,37 +6,54 @@ release:
name_template: "v{{.Version}}" name_template: "v{{.Version}}"
before: before:
hooks: hooks:
- npm i - go mod download
- make precompile - rm -rf data/web
- mkdir -p data/web/css
- bash -c 'cp -r static/* data/web/'
- npm install
- npm install esbuild
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- node scripts/missing-colors.js html data/html
- cp -r lang data/
- cp LICENSE data/
- cp jfa-go.service data/
- python3 scripts/enumerate_config.py -i config/config-base.json -o data/config-base.json
- python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 scripts/compile_mjml.py -o data/
- rm -rf tempts
- cp -r ts tempts
- scripts/dark-variant.sh tempts
- scripts/dark-variant.sh tempts/modules
- mkdir -p data/web/js
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}
- bash -c "{{.Env.JFA_GO_COPYTS}}"
- rm -r tempts
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
- cp html/crash.html data/
- npx tailwindcss -i data/web/css/bundle.css -o data/bundle.css --content "html/crash.html"
- node scripts/inline.js root data data/crash.html data/crash.html
- rm data/bundle.css
- npx tailwindcss -i data/web/css/bundle.css -o data/web/css/bundle.css
- mv data/crash.html data/html/
- go install github.com/swaggo/swag/cmd/swag@latest
- swag init -g main.go
- mv data/web/css/bundle.css data/web/css/{{.Env.JFA_GO_CSS_VERSION}}bundle.css
builds: builds:
- id: notray - id: notray
dir: ./ dir: ./
flags: env:
- -tags={{ .Env.JFA_GO_TAG }} - CGO_ENABLED=0
ldflags: ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" - -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos: goos:
- linux - linux
- darwin - darwin
- windows
goarch:
- arm
- arm64
- amd64
- id: notray-e2ee
dir: ./
env:
- CGO_ENABLED=1
- CC={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
- CXX={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
- GOARM={{ if eq .Arch "arm" }}7{{ end }}
flags:
- -tags=e2ee,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
goarch: goarch:
- arm - arm
- arm64 - arm64
@ -50,9 +65,9 @@ builds:
- CC=x86_64-w64-mingw32-gcc - CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++ - CXX=x86_64-w64-mingw32-g++
flags: flags:
- -tags=tray,{{ .Env.JFA_GO_TAG }} - -tags=tray
ldflags: ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui - -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
goos: goos:
- windows - windows
goarch: goarch:
@ -61,13 +76,10 @@ builds:
dir: ./ dir: ./
env: env:
- CGO_ENABLED=1 - CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-gcc
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
flags: flags:
- -tags=tray,e2ee,{{ .Env.JFA_GO_TAG }} - -tags=tray
ldflags: ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" - -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos: goos:
- linux - linux
goarch: goarch:
@ -103,16 +115,6 @@ archives:
{{- else }}{{- title .Os }}{{ end }}_ {{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64 {{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }} {{- else }}{{ .Arch }}{{ end }}
- id: notray-e2ee
builds:
- notray-e2ee
format: zip
name_template: >-
{{ .ProjectName }}_{{ .Version }}_MatrixE2EE_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
snapshot: snapshot:
@ -134,7 +136,7 @@ nfpms:
vendor: hrfee.dev vendor: hrfee.dev
version_metadata: git version_metadata: git
builds: builds:
- notray-e2ee - notray
contents: contents:
- src: ./LICENSE - src: ./LICENSE
dst: /usr/share/licenses/jfa-go dst: /usr/share/licenses/jfa-go
@ -142,16 +144,6 @@ nfpms:
- apk - apk
- deb - deb
- rpm - rpm
overrides:
deb:
dependencies:
- libolm-dev
rpm:
dependencies:
- libolm
apk:
dependencies:
- olm
- id: tray - id: tray
file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_TrayIcon_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}' file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_TrayIcon_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}'
package_name: jfa-go-tray package_name: jfa-go-tray
@ -178,12 +170,9 @@ nfpms:
- jfa-go - jfa-go
dependencies: dependencies:
- libayatana-appindicator - libayatana-appindicator
- libolm-dev
rpm: rpm:
dependencies: dependencies:
- libappindicator-gtk3 - libappindicator-gtk3
- libolm
apk: apk:
dependencies: dependencies:
- libayatana-appindicator - libayatana-appindicator
- olm

View File

@ -1,51 +0,0 @@
when:
- event: push
branch: main
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- name: redoc
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REDOC_SSH_ID:
from_secret: REDOC_SSH_ID
commands:
- sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
- bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'

View File

@ -1,29 +0,0 @@
when:
- event: push
branch: main
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: unstable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true

View File

@ -1,41 +0,0 @@
when:
- event: tag
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sfL https://goreleaser.com/static/run > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'

View File

@ -1,29 +0,0 @@
when:
- event: tag
branch: main
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: latest
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true

View File

@ -3,5 +3,42 @@ title: "Building/Contributing for developers"
date: 2021-07-25T00:33:36+01:00 date: 2021-07-25T00:33:36+01:00
draft: false draft: false
--- ---
# Code
I use 4 spaces for indentation. Go should ideally be formatted with `goimports` and/or `gofmt`. I don't use a formatter on typescript, so don't worry about that.
[See the wiki page](https://wiki.jfa-go.com/docs/dev/). Code in Go should ideally use `PascalCase` for exported values, and `camelCase` for non-exported, JSON for transferring data should use `snake_case`, and Typescript should use `camelCase`. Forgive me for my many inconsistencies in this, and feel free to fix them if you want.
Functions in Go that need to access `*appContext` should be generally be receivers, except when the behaviour could be seen as somewhat independent from it (`email.go` is the best example, its behaviour is broadly independent from the main app except from a couple config values).
# Compiling
The Makefile is more suited towards development than other build methods, and provides separate build stages to speed up compilation when only making changes to specific aspects of the project.
Prefix each of these with `make DEBUG=on `:
* `all` will download deps and build everything. The executable and data will be placed in `build`. This is only necessary the first time.
* `npm` will download all node.js build-time dependencies.
* `compile` will only compile go code into the `build/jfa-go` executable.
* `typescript` will compile typescript w/ sourcemaps into `build/data/web/js`.
* `bundle-css` will bundle CSS and place it in `build/data/web/css`.
* `inline` will inline the css and javascript used in the single-file crash report webpage.
* `configuration` will generate the `config-base.json` (used to render settings in the web ui) and `config-default.ini` and put them in `build/data`.
* `email` will compile email mjml, and copy the text versions in to `build/data`.
* `swagger`: generates swagger documentation for the API.
* `copy` will copy iconography, html, language files and static data into `build/data`.
## Environment variables
* `DEBUG=on/off`: If on, compiles with type-checking for typescript, sourcemaps, non-minified css and no symbol stripping.
* `INTERNAL=on/off`: Whether or not to embed file assets into the binary itself, or store them separately beside the binary.
* `UPDATER=on/off/docker`: Enable/Disable the updater, or set a special update type (currently only docker, which disables self-updating the binary).
* `TRAY=on/off`: Enable/disable the tray icon, which lets you start/stop/autostart on login. For linux, requires `libappindicator3-dev` for debian or the equivalent on other distributions.
* `GOESBUILD=on`: Use a locally installed `esbuild` binary. NPM doesn't provide builds for all os/architectures, so `npx esbuild` might not work for you, so the binary is compiled/installed with `go get`.
* `GOBINARY=<path to go>`: Alternative path to go executable. Useful for testing with unstable go releases.
* `VERSION=v<semver>`: Alternative verision number, useful to test update functionality.
* `COMMIT=<short commit>`: Self explanatory.
* `LDFLAGS=<ldflags>`: Passed to `go build -ldflags`.
* `E2EE=on/off`: Enable/disable end-to-end encryption support for Matrix, which is currently very broken. Must subsequently be enabled (with Advanced settings enabled) in Settings > Matrix.
* `TAGS=<tags>`: Passed to `go build -tags`.
* `OS=<os>`: Unrelated to GOOS, if set to `windows`, `-H=windowsgui` is passed to ldflags, which stops a windows terminal popping up when run.
* `RACE=on/off`: If on, compiles with the go race detector included.

View File

@ -1,26 +1,30 @@
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture FROM --platform=$BUILDPLATFORM golang:latest AS support
# FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
# FROM --platform=$BUILDPLATFORM jfa-go-bd AS support
ARG BUILT_BY
ENV JFA_GO_BUILT_BY=$BUILT_BY
COPY . /opt/build COPY . /opt/build
# RUN curl -sfL https://goreleaser.com/static/run > /goreleaser && chmod +x /goreleaser RUN apt-get update -y \
RUN cd /opt/build; INTERNAL=off UPDATER=docker ./scripts/version.sh /goreleaser build --snapshot --skip=validate --clean --id notray-e2ee && apt-get install build-essential python3-pip curl software-properties-common sed -y \
RUN mv /opt/build/dist/*_linux_arm_6 /opt/build/dist/placeholder_linux_arm && (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
RUN sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html && apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM golang:bookworm AS final
FROM --platform=$BUILDPLATFORM golang:latest AS build
ARG TARGETARCH ARG TARGETARCH
ENV GOARCH=$TARGETARCH
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /opt/jfa-go COPY --from=support /opt/build /opt/build
COPY --from=support /opt/build/build/data /opt/jfa-go/data
RUN apt-get update -y && apt-get install libolm-dev -y RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
FROM golang:latest
COPY --from=build /opt/build/build /opt/jfa-go
EXPOSE 8056 EXPOSE 8056
EXPOSE 8057 EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ] CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]

170
Makefile
View File

@ -1,7 +1,3 @@
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
all: compile
GOESBUILD ?= off GOESBUILD ?= off
ifeq ($(GOESBUILD), on) ifeq ($(GOESBUILD), on)
ESBUILD := esbuild ESBUILD := esbuild
@ -11,7 +7,6 @@ endif
GOBINARY ?= go GOBINARY ?= go
CSSVERSION ?= v3 CSSVERSION ?= v3
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit) VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
VERSION := $(shell echo $(VERSION) | sed 's/v//g') VERSION := $(shell echo $(VERSION) | sed 's/v//g')
@ -30,7 +25,7 @@ endif
INTERNAL ?= on INTERNAL ?= on
TRAY ?= off TRAY ?= off
E2EE ?= on E2EE ?= off
TAGS := -tags " TAGS := -tags "
ifeq ($(INTERNAL), on) ifeq ($(INTERNAL), on)
@ -58,19 +53,17 @@ endif
DEBUG ?= off DEBUG ?= off
ifeq ($(DEBUG), on) ifeq ($(DEBUG), on)
SOURCEMAP := --sourcemap SOURCEMAP := --sourcemap
MINIFY :=
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
# jank # jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
UNCSS := cp $(CSS_BUNDLE) $(DATA)/bundle.css UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
# TAILWIND := --content "" # TAILWIND := --content ""
else else
LDFLAGS := -s -w $(LDFLAGS) LDFLAGS := -s -w $(LDFLAGS)
SOURCEMAP := SOURCEMAP :=
MINIFY := --minify
COPYTS := COPYTS :=
TYPECHECK := TYPECHECK :=
UNCSS := npx tailwindcss -i $(CSS_BUNDLE) -o $(DATA)/bundle.css --content "html/crash.html" UNCSS := npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/bundle.css --content "html/crash.html"
# UNCSS := npx uncss $(DATA)/crash.html --csspath web/css --output $(DATA)/bundle.css # UNCSS := npx uncss $(DATA)/crash.html --csspath web/css --output $(DATA)/bundle.css
TAILWIND := TAILWIND :=
endif endif
@ -101,133 +94,94 @@ else
SWAGINSTALL := SWAGINSTALL :=
endif endif
CONFIG_BASE = config/config-base.yaml npm:
$(info installing npm dependencies)
npm install $(NPMOPTS)
# CONFIG_DESCRIPTION = $(DATA)/config-base.json configuration:
CONFIG_DEFAULT = $(DATA)/config-default.ini $(info Fixing config-base)
# $(CONFIG_DESCRIPTION) &: $(CONFIG_BASE) -mkdir -p $(DATA)
# $(info Fixing config-base) python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
# -mkdir -p $(DATA)
$(DATA):
mkdir -p $(DATA)
$(CONFIG_DEFAULT): $(DATA) $(CONFIG_BASE)
$(info Generating config-default.ini) $(info Generating config-default.ini)
go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
configuration: $(CONFIG_DEFAULT) email:
EMAIL_SRC_MJML = $(wildcard mail/*.mjml)
EMAIL_SRC_TXT = $(wildcard mail/*.txt)
EMAIL_DATA_MJML = $(EMAIL_SRC_MJML:mail/%=data/%)
EMAIL_HTML = $(EMAIL_DATA_MJML:.mjml=.html)
EMAIL_TXT = $(EMAIL_SRC_TXT:mail/%=data/%)
EMAIL_ALL = $(EMAIL_HTML) $(EMAIL_TXT)
EMAIL_TARGET = mail/confirmation.html
$(EMAIL_TARGET): $(EMAIL_SRC_MJML) $(EMAIL_SRC_TXT)
$(info Generating email html) $(info Generating email html)
npx mjml mail/*.mjml -o $(DATA)/ python3 scripts/compile_mjml.py -o $(DATA)/
$(info Copying plaintext mail)
cp mail/*.txt $(DATA)/
TYPESCRIPT_FULLSRC = $(shell find ts/ -type f -name "*.ts") typescript:
TYPESCRIPT_SRC = $(wildcard ts/*.ts)
TYPESCRIPT_TEMPSRC = $(TYPESCRIPT_SRC:ts/%=tempts/%)
# TYPESCRIPT_TARGET = $(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(TYPESCRIPT_TEMPSRC)))
TYPESCRIPT_TARGET = $(DATA)/web/js/admin.js
$(TYPESCRIPT_TARGET): $(TYPESCRIPT_FULLSRC) ts/tsconfig.json
$(TYPECHECK) $(TYPECHECK)
$(adding dark variants to typescript)
rm -rf tempts rm -rf tempts
cp -r ts tempts cp -r ts tempts
$(adding dark variants to typescript)
scripts/dark-variant.sh tempts scripts/dark-variant.sh tempts
scripts/dark-variant.sh tempts/modules scripts/dark-variant.sh tempts/modules
$(info compiling typescript) $(info compiling typescript)
mkdir -p $(DATA)/web/js mkdir -p $(DATA)/web/js
$(foreach tempsrc,$(TYPESCRIPT_TEMPSRC),$(ESBUILD) --target=es6 --bundle $(tempsrc) $(SOURCEMAP) --outfile=$(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(tempsrc))) $(MINIFY);) $(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
mv $(DATA)/web/js/crash.js $(DATA)/ $(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
$(COPYTS) $(COPYTS)
SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go swagger:
SWAGGER_TARGET = docs/docs.go
$(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL) $(SWAGINSTALL)
swag init -g main.go swag init -g main.go
VARIANTS_SRC = $(wildcard html/*.html) compile:
VARIANTS_TARGET = $(DATA)/html/admin.html $(info Downloading deps)
$(VARIANTS_TARGET): $(VARIANTS_SRC) $(GOBINARY) mod download
$(info Building)
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o build/jfa-go
compress:
upx --lzma build/jfa-go
bundle-css:
mkdir -p $(DATA)/web/css
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
$(info bundling css)
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/web/css/bundle.css $(TAILWIND)
# npx postcss -o $(DATA)/web/css/bundle.css $(DATA)/web/css/bundle.css
inline-css:
cp html/crash.html $(DATA)/crash.html
$(UNCSS)
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
rm $(DATA)/bundle.css
variants-html:
$(info copying html) $(info copying html)
cp -r html $(DATA)/ cp -r html $(DATA)/
$(info adding dark variants to html) $(info adding dark variants to html)
node scripts/missing-colors.js html $(DATA)/html node scripts/missing-colors.js html $(DATA)/html
ICON_SRC = node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 copy:
ICON_TARGET = $(ICON_SRC:node_modules/remixicon/fonts/%=$(DATA)/web/css/%)
CSS_SRC = $(wildcard css/*.css)
CSS_TARGET = $(DATA)/web/css/part-bundle.css
CSS_FULLTARGET = $(CSS_BUNDLE)
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC)
ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
mkdir -p $(DATA)/web/css
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
$(info bundling css)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(CSS_TARGET) -o $(CSS_FULLTARGET) $(TAILWIND)
rm $(CSS_TARGET)
# mv $(CSS_BUNDLE) $(DATA)/web/css/$(CSSVERSION)bundle.css
# npx postcss -o $(CSS_TARGET) $(CSS_TARGET)
INLINE_SRC = html/crash.html
INLINE_TARGET = $(DATA)/crash.html
$(INLINE_TARGET): $(CSS_FULLTARGET) $(INLINE_SRC)
cp html/crash.html $(DATA)/crash.html
$(UNCSS) # generates $(DATA)/bundle.css for us
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
rm $(DATA)/bundle.css
LANG_SRC = $(shell find ./lang)
LANG_TARGET = $(LANG_SRC:lang/%=$(DATA)/lang/%)
STATIC_SRC = $(wildcard static/*)
STATIC_TARGET = $(STATIC_SRC:static/%=$(DATA)/web/%)
COPY_SRC = images/banner.svg jfa-go.service LICENSE $(LANG_SRC) $(STATIC_SRC)
COPY_TARGET = $(DATA)/jfa-go.service
# $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css
$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC)
$(info copying $(CONFIG_BASE))
cp $(CONFIG_BASE) $(DATA)/
$(info copying crash page) $(info copying crash page)
cp $(DATA)/crash.html $(DATA)/html/ mv $(DATA)/crash.html $(DATA)/html/
$(info copying static data) $(info copying static data)
mkdir -p $(DATA)/web mkdir -p $(DATA)/web
cp images/banner.svg static/banner.svg
cp -r static/* $(DATA)/web/ cp -r static/* $(DATA)/web/
$(info copying systemd service) $(info copying systemd service)
cp jfa-go.service $(DATA)/ cp jfa-go.service $(DATA)/
$(info copying language files) $(info copying language files)
cp -r lang $(DATA)/ cp -r lang $(DATA)/
cp LICENSE $(DATA)/ cp LICENSE $(DATA)/
mv $(DATA)/web/css/bundle.css $(DATA)/web/css/$(CSSVERSION)bundle.css
precompile: $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) # internal-files:
# python3 scripts/embed.py internal
GO_SRC = $(shell find ./ -name "*.go") #
GO_TARGET = build/jfa-go # external-files:
$(GO_TARGET): $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum # python3 scripts/embed.py external
$(info Downloading deps) # -mkdir -p build
$(GOBINARY) mod download # $(info copying internal data into build/)
$(info Building) # cp -r data build/
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
compile: $(GO_TARGET)
compress:
upx --lzma build/jfa-go
install: install:
cp -r build $(DESTDIR)/jfa-go cp -r build $(DESTDIR)/jfa-go
@ -239,6 +193,6 @@ clean:
-rm docs/docs.go docs/swagger.json docs/swagger.yaml -rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean go clean
npm: quick: configuration typescript variants-html bundle-css inline-css copy compile
$(info installing npm dependencies)
npm install $(NPMOPTS) all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile

View File

@ -1,5 +1,5 @@
![jfa-go](images/banner.svg) ![jfa-go](images/banner.svg)
[![Build Status](https://ci.hrfee.dev/api/badges/3/status.svg)](https://ci.hrfee.dev/repos/3) [![Build Status](https://drone.hrfee.dev/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.dev/hrfee/jfa-go)
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go) [![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/svg-badge.svg)](https://weblate.jfa-go.com/engage/jfa-go/) [![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/svg-badge.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
[![Docs/Wiki](https://img.shields.io/static/v1?label=documentation&message=jfa-go.com&color=informational)](https://wiki.jfa-go.com) [![Docs/Wiki](https://img.shields.io/static/v1?label=documentation&message=jfa-go.com&color=informational)](https://wiki.jfa-go.com)
@ -13,35 +13,41 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all). Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work? #### Does/Will it still work?
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur. jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
#### Alternatives #### Alternatives
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself. If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
* [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration. * [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration.
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr which can manage users and mainly acts as an Ombi alternative. * [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what. * [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
--- ---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage. jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
😂).
#### Features #### Features
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you. * 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email, discord, telegram or matrix * Send invites via a link and/or email
* Granular control over invites: Validity period as well as number of uses can be specified. * Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation. * Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password. * Password validation: Ensure users choose a strong password.
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots. * CAPTCHAs can be enabled to avoid bots
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too. * ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**. * 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more. * Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it. * Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown. * "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Custom messages can be added, with markdown.
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky. * Referrals: Users can be given special invites to send to their friends and families.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram. * 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Can also be done through the "My Account" page if enabled. * Can also be done through the "My Account" page if enabled.
* Admin Notifications: Get notified when someone creates an account, or an invite expires. * Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizations * 🌓 Customizations
* Customize emails with variables and markdown * Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages * Specify contact and help messages to appear in emails and pages
@ -59,8 +65,6 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively. **Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
`MatrixE2EE` builds (and Linux `TrayIcon` builds) include support for end-to-end encryption for the Matrix bot, but require the `libolm(-dev)` dependency. `.deb/.rpm/.apk` packages list this dependency, and docker images include it.
##### [Docker](https://hub.docker.com/r/hrfee/jfa-go) ##### [Docker](https://hub.docker.com/r/hrfee/jfa-go)
```sh ```sh
docker create \ docker create \
@ -68,7 +72,7 @@ docker create \
-p 8056:8056 \ -p 8056:8056 \
# -p 8057:8057 if using tls # -p 8057:8057 if using tls
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data -v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby -v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct -v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
``` ```
@ -76,7 +80,7 @@ docker create \
##### [Debian/Ubuntu](https://apt.hrfee.dev) ##### [Debian/Ubuntu](https://apt.hrfee.dev)
```sh ```sh
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
# For stable releases # For stable releases
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
@ -90,7 +94,7 @@ sudo apt-get update
# For servers # For servers
sudo apt-get install jfa-go sudo apt-get install jfa-go
# ------ # ------
# For desktops/servers with GUI (may pull in lots of dependencies) # For desktops/servers with GUI (has dependencies)
sudo apt-get install jfa-go-tray sudo apt-get install jfa-go-tray
# ------ # ------
``` ```
@ -104,7 +108,7 @@ Available on the AUR as:
##### Other platforms ##### Other platforms
Download precompiled binaries from: Download precompiled binaries from:
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable) * [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly) * [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful. unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`. * For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
@ -143,8 +147,6 @@ Usage of jfa-go:
alternate port to host web ui on. alternate port to host web ui on.
-pprof -pprof
Exposes pprof profiler on /debug/pprof. Exposes pprof profiler on /debug/pprof.
-restore string
path to database backup to restore.
-swagger -swagger
Enable swagger at /swagger/index.html Enable swagger at /swagger/index.html
``` ```
@ -152,9 +154,18 @@ Usage of jfa-go:
#### Systemd #### Systemd
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else. jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
---
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### Contributing #### Contributing
See [the wiki page](https://wiki.jfa-go.com/docs/dev/). See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
##### Translation ##### Translation
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/multi-auto.svg)](https://weblate.jfa-go.com/engage/jfa-go/) [![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/multi-auto.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
@ -164,3 +175,4 @@ For translations, use the weblate instance [here](https://weblate.jfa-go.com/eng
Big thanks to those who sponsor me. You can see them below: Big thanks to those who sponsor me. You can see them below:
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/0" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0) [<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/0" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/1" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)

View File

@ -1,188 +0,0 @@
package main
import (
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
return "creation"
case ActivityDeletion:
return "deletion"
case ActivityDisabled:
return "disabled"
case ActivityEnabled:
return "enabled"
case ActivityContactLinked:
return "contactLinked"
case ActivityContactUnlinked:
return "contactUnlinked"
case ActivityChangePassword:
return "changePassword"
case ActivityResetPassword:
return "resetPassword"
case ActivityCreateInvite:
return "createInvite"
case ActivityDeleteInvite:
return "deleteInvite"
}
return "unknown"
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
return ActivityUser
case "admin":
return ActivityAdmin
case "anon":
return ActivityAnon
case "daemon":
return ActivityDaemon
}
return ActivityAnon
}
func activitySourceToString(v ActivitySource) string {
switch v {
case ActivityUser:
return "user"
case ActivityAdmin:
return "admin"
case ActivityAnon:
return "anon"
case ActivityDaemon:
return "daemon"
}
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
activityTypes := make([]interface{}, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
}
if !req.Ascending {
query = query.Reverse()
}
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
}
for i, act := range results {
resp.Activities[i] = ActivityDTO{
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
resp.Activities[i].Value = ""
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, err := app.jf.UserByID(act.Source, false)
if err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
gc.JSON(200, resp)
}
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
// @Produce json
// @Param id path string true "ID of activity to delete"
// @Success 200 {object} boolResponse
// @Router /activity/{id} [delete]
// @Security Bearer
// @tags Activity
func (app *appContext) DeleteActivity(gc *gin.Context) {
app.storage.DeleteActivityKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} GetActivityCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}

View File

@ -1,118 +0,0 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @Summary Creates a backup of the database.
// @Router /backups [post]
// @Success 200 {object} CreateBackupDTO
// @Security Bearer
// @tags Backups
func (app *appContext) CreateBackup(gc *gin.Context) {
backup := app.makeBackup()
gc.JSON(200, backup)
}
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
// @Param fname path string true "backup filename"
// @Router /backups/{fname} [get]
// @Produce octet-stream
// @Produce json
// @Success 200 {body} file
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
gc.FileAttachment(fullpath, fname)
}
// @Summary Get a list of backups.
// @Router /backups [get]
// @Produce json
// @Success 200 {object} GetBackupsDTO
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackups(gc *gin.Context) {
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
sort.Sort(backups)
resp := GetBackupsDTO{}
resp.Backups = make([]CreateBackupDTO, backups.count)
for i, item := range backups.files[:backups.count] {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
resp.Backups[i].Date = backups.dates[i].Unix()
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
}
}
gc.JSON(200, resp)
}
// @Summary Restore a backup file stored locally to the server.
// @Param fname path string true "backup filename"
// @Router /backups/restore/{fname} [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
LOADBAK = fullpath
app.restart(gc)
}
// @Summary Restore a backup file uploaded by the user.
// @Param file formData file true ".bak file"
// @Router /backups/restore [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
if err != nil {
app.err.Printf(lm.FailedGetUpload, err)
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.GetUpload, file.Filename)
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath
app.restart(gc)
}

View File

@ -8,28 +8,15 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
) )
const ( const (
CAPTCHA_VALIDITY = 20 * 60 // Seconds CAPTCHA_VALIDITY = 20 * 60 // Seconds
) )
// GenerateInviteCode generates an invite code in the correct format.
func GenerateInviteCode() string {
// make sure code doesn't begin with number
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
return inviteCode
}
// checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data.
func (app *appContext) checkInvites() { func (app *appContext) checkInvites() {
currentTime := time.Now() currentTime := time.Now()
for _, data := range app.storage.GetInvites() { for _, data := range app.storage.GetInvites() {
@ -46,18 +33,49 @@ func (app *appContext) checkInvites() {
app.storage.SetInvitesKey(data.Code, data) app.storage.SetInvitesKey(data.Code, data)
} }
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") { if data.IsReferral {
continue continue
} }
expiry := data.ValidTill expiry := data.ValidTill
if !currentTime.After(expiry) { if !currentTime.After(expiry) {
continue continue
} }
app.deleteExpiredInvite(data) app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
app.storage.DeleteInvitesKey(data.Code)
} }
} }
// checkInvite checks the validity of a specific invite, optionally removing it if invalid(ated).
func (app *appContext) checkInvite(code string, used bool, username string) bool { func (app *appContext) checkInvite(code string, used bool, username string) bool {
currentTime := time.Now() currentTime := time.Now()
inv, match := app.storage.GetInvitesKey(code) inv, match := app.storage.GetInvitesKey(code)
@ -66,21 +84,46 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
} }
expiry := inv.ValidTill expiry := inv.ValidTill
if currentTime.After(expiry) { if currentTime.After(expiry) {
app.deleteExpiredInvite(inv) app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(code, inv, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
match = false match = false
app.storage.DeleteInvitesKey(code)
} else if used { } else if used {
del := false del := false
newInv := inv newInv := inv
if newInv.RemainingUses == 1 { if newInv.RemainingUses == 1 {
del = true del = true
app.storage.DeleteInvitesKey(code) app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if newInv.RemainingUses != 0 { } else if newInv.RemainingUses != 0 {
// 0 means infinite i guess? // 0 means infinite i guess?
newInv.RemainingUses-- newInv.RemainingUses--
@ -93,67 +136,6 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
return match return match
} }
func (app *appContext) deleteExpiredInvite(data Invite) {
app.debug.Printf(lm.DeleteOldInvite, data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
wait := app.sendAdminExpiryNotification(data)
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
if wait != nil {
wait.Wait()
}
}
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 {
return nil
}
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
// Check whether notify "address" is an email address or Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err)
} else {
app.info.Printf(lm.SentExpiryAdmin, data.Code, addr)
}
}
}(address)
}
return &wait
}
// @Summary Create a new invite. // @Summary Create a new invite.
// @Produce json // @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object" // @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@ -163,13 +145,19 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
// @tags Invites // @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) { func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO var req generateInviteDTO
app.debug.Println(lm.GenerateInvite) app.debug.Println("Generating new invite")
gc.BindJSON(&req) gc.BindJSON(&req)
currentTime := time.Now() currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days) validTill := currentTime.AddDate(0, req.Months, req.Days)
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes)) validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
// make sure code doesn't begin with number
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
var invite Invite var invite Invite
invite.Code = GenerateInviteCode()
if req.Label != "" { if req.Label != "" {
invite.Label = req.Label invite.Label = req.Label
} }
@ -197,12 +185,13 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false addressValid := false
discord := "" discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) { app.debug.Printf("%s: Sending invite message", inviteCode)
if discordEnabled && !strings.Contains(req.SendTo, "@") {
users := app.discord.GetUsers(req.SendTo) users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 { if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo) invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
} else if len(users) > 1 { } else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo) invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
} else { } else {
invite.SendTo = req.SendTo invite.SendTo = req.SendTo
addressValid = true addressValid = true
@ -213,12 +202,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.SendTo = req.SendTo invite.SendTo = req.SendTo
} }
if addressValid { if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false) msg, err := app.email.constructInvite(inviteCode, invite, app, false)
if err != nil { if err != nil {
// Slight misuse of the template invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err) app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else { } else {
var err error var err error
if discord != "" { if discord != "" {
@ -227,10 +214,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
err = app.email.send(msg, req.SendTo) err = app.email.send(msg, req.SendTo)
} }
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err) invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Println(invite.SendTo) app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
} else { } else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo) app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
} }
} }
} }
@ -242,19 +229,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Profile = "Default" invite.Profile = "Default"
} }
} }
app.storage.SetInvitesKey(invite.Code, invite) app.storage.SetInvitesKey(inviteCode, invite)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreateInvite,
UserID: "",
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -265,6 +240,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) { func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
currentTime := time.Now() currentTime := time.Now()
app.checkInvites() app.checkInvites()
var invites []inviteDTO var invites []inviteDTO
@ -272,8 +248,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.IsReferral { if inv.IsReferral {
continue continue
} }
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{ invite := inviteDTO{
Code: inv.Code, Code: inv.Code,
Months: months, Months: months,
@ -299,7 +274,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if err != nil { if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern) date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
if err != nil { if err != nil {
app.err.Printf(lm.FailedParseTime, err) app.err.Printf("Failed to parse usedBy time: %v", err)
} }
unix = date.Unix() unix = date.Unix()
} }
@ -314,6 +289,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
invite.SendTo = inv.SendTo invite.SendTo = inv.SendTo
} }
if len(inv.Notify) != 0 { if len(inv.Notify) != 0 {
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
var addressOrID string var addressOrID string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId") addressOrID = gc.GetString("jfId")
@ -331,7 +307,22 @@ func (app *appContext) GetInvites(gc *gin.Context) {
} }
invites = append(invites, invite) invites = append(invites, invite)
} }
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getInvitesDTO{ resp := getInvitesDTO{
Profiles: profiles,
Invites: invites, Invites: invites,
} }
gc.JSON(200, resp) gc.JSON(200, resp)
@ -344,13 +335,14 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /invites/profile [post] // @Router /invites/profile [post]
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Profiles & Settings
func (app *appContext) SetProfile(gc *gin.Context) { func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO var req inviteProfileDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
// "" means "Don't apply profile" // "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" { if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf(lm.FailedGetProfile, req.Profile) app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
respond(500, "Profile not found", gc) respond(500, "Profile not found", gc)
return return
} }
@ -374,11 +366,11 @@ func (app *appContext) SetNotify(gc *gin.Context) {
gc.BindJSON(&req) gc.BindJSON(&req)
changed := false changed := false
for code, settings := range req { for code, settings := range req {
app.debug.Printf("%s: Notification settings change requested", code)
invite, ok := app.storage.GetInvitesKey(code) invite, ok := app.storage.GetInvitesKey(code)
if !ok { if !ok {
msg := fmt.Sprintf(lm.InvalidInviteCode, code) app.err.Printf("%s Notification setting change failed: Invalid code", code)
app.err.Println(msg) respond(400, "Invalid invite code", gc)
respond(400, msg, gc)
return return
} }
var address string var address string
@ -386,8 +378,9 @@ func (app *appContext) SetNotify(gc *gin.Context) {
if jellyfinLogin { if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != "" var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable { if !addressAvailable {
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId")) app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc) app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user contact method", gc)
return return
} }
address = gc.GetString("jfId") address = gc.GetString("jfId")
@ -402,12 +395,15 @@ func (app *appContext) SetNotify(gc *gin.Context) {
} /*else { } /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok { if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/ */
for _, notifyType := range []string{"notify-expiry", "notify-creation"} { if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] { invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
invite.Notify[address][notifyType] = settings[notifyType] app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true changed = true
} }
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
changed = true
} }
if changed { if changed {
app.storage.SetInvitesKey(code, invite) app.storage.SetInvitesKey(code, invite)
@ -426,24 +422,15 @@ func (app *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) { func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO var req deleteInviteDTO
gc.BindJSON(&req) gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Code) app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
if ok { if ok {
app.storage.DeleteInvitesKey(req.Code) app.storage.DeleteInvitesKey(req.Code)
app.info.Printf("%s: Invite deleted", req.Code)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
}, gc, false)
app.info.Printf(lm.DeleteInvite, req.Code)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code") app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
respond(400, "Code doesn't exist", gc) respond(400, "Code doesn't exist", gc)
} }

View File

@ -1,165 +0,0 @@
package main
import (
"fmt"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @Summary Get a list of Jellyseerr users.
// @Produce json
// @Success 200 {object} ombiUsersDTO
// @Failure 500 {object} stringResponse
// @Router /jellyseerr/users [get]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
users, err := app.js.GetUsers()
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get users", gc)
return
}
userlist := make([]ombiUser, len(users))
i := 0
for _, u := range users {
userlist[i] = ombiUser{
Name: u.Name(),
ID: strconv.FormatInt(u.ID, 10),
}
i++
}
gc.JSON(200, ombiUsersDTO{Users: userlist})
}
// @Summary Store Jellyseerr user template in an existing profile.
// @Produce json
// @Param id path string true "Jellyseerr ID of user to source from"
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile}/{id} [post]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64)
if err != nil {
respondBool(400, false, gc)
return
}
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc)
return
}
profile.Jellyseerr.User = u.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
if err != nil {
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, gc.Param("id"), err)
respond(500, "Couldn't get user notification prefs", gc)
return
}
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
// @Summary Remove jellyseerr user template from a profile.
// @Produce json
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile} [delete]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
profile.Jellyseerr.Enabled = false
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
type JellyseerrWrapper struct {
*jellyseerr.Jellyseerr
}
func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
// Gets existing user (not possible) or imports the given user.
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
ok = true
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
return
}
err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err)
return
}
return
}
func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
}
}
if discordEnabled && discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
if err != nil {
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
return
}
}
return
}
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
}

View File

@ -5,9 +5,6 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
@ -34,14 +31,12 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled}, "UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled}, "UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled}, "UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled}, "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled}, "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled}, "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled}, "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("Login").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled}, "UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("Page").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
} }
filter := gc.Query("filter") filter := gc.Query("filter")
@ -55,6 +50,39 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
gc.JSON(200, list) gc.JSON(200, list)
} }
// No longer needed, these are stored by string keys in the database now.
/* func (app *appContext) getCustomMessage(id string) *CustomContent {
switch id {
case "Announcement":
return &CustomContent{}
case "UserCreated":
return &app.storage.customEmails.UserCreated
case "InviteExpiry":
return &app.storage.customEmails.InviteExpiry
case "PasswordReset":
return &app.storage.customEmails.PasswordReset
case "UserDeleted":
return &app.storage.customEmails.UserDeleted
case "UserDisabled":
return &app.storage.customEmails.UserDisabled
case "UserEnabled":
return &app.storage.customEmails.UserEnabled
case "InviteEmail":
return &app.storage.customEmails.InviteEmail
case "WelcomeEmail":
return &app.storage.customEmails.WelcomeEmail
case "EmailConfirmation":
return &app.storage.customEmails.EmailConfirmation
case "UserExpired":
return &app.storage.customEmails.UserExpired
case "UserLogin":
return &app.storage.userPage.Login
case "UserPage":
return &app.storage.userPage.Page
}
return nil
} */
// @Summary Sets the corresponding custom content. // @Summary Sets the corresponding custom content.
// @Produce json // @Produce json
// @Param CustomContent body CustomContent true "Content = email (in markdown)." // @Param CustomContent body CustomContent true "Content = email (in markdown)."
@ -135,7 +163,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id) customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok && id != "Announcement" { if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id) app.err.Printf("Failed to get custom message with ID \"%s\"", id)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -148,11 +176,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
} else if id == "UserLogin" { } else if id == "UserLogin" {
variables = []string{} variables = []string{}
customMessage.Variables = variables customMessage.Variables = variables
} else if id == "PostSignupCard" {
variables = []string{"{username}", "{myAccountURL}"}
customMessage.Variables = variables
} }
content = customMessage.Content content = customMessage.Content
noContent := content == "" noContent := content == ""
if !noContent { if !noContent {
@ -192,11 +216,6 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructEnabled("", app, true) msg, err = app.email.constructEnabled("", app, true)
} }
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted":
if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
}
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail": case "InviteEmail":
if noContent { if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true) msg, err = app.email.constructInvite("", Invite{}, app, true)
@ -217,14 +236,14 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructUserExpired(app, true) msg, err = app.email.constructUserExpired(app, true)
} }
values = app.email.userExpiredValues(app, false) values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage", "PostSignupCard": case "UserLogin", "UserPage":
values = map[string]interface{}{} values = map[string]interface{}{}
} }
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" { if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
content = msg.Text content = msg.Text
variables = make([]string, strings.Count(content, "{")) variables = make([]string, strings.Count(content, "{"))
i := 0 i := 0
@ -250,32 +269,17 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
} }
app.storage.SetCustomContentKey(id, customMessage) app.storage.SetCustomContentKey(id, customMessage)
var mail *Message var mail *Message
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" { if id != "UserLogin" && id != "UserPage" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app) mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
} else if id == "PostSignupCard" {
// Jankiness follows.
// Source content from "Success Message" setting.
if noContent {
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
})
}
}
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
}
mail.Markdown = mail.HTML
} else { } else {
mail = &Message{ mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>", HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
} }
mail.Markdown = mail.HTML
} }
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text}) gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
} }
@ -324,14 +328,6 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
tgUser.Lang = lang tgUser.Lang = lang
} }
app.storage.SetTelegramKey(req.ID, tgUser) app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -356,14 +352,16 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
} }
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) { func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
jsPrefs := map[jellyseerr.NotificationsField]any{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok { if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser) app.storage.SetTelegramKey(req.ID, tgUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram) msg := ""
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
} }
} }
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok { if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@ -371,8 +369,11 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
dcUser.Contact = req.Discord dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser) app.storage.SetDiscordKey(req.ID, dcUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord) msg := ""
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
} }
} }
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok { if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@ -380,7 +381,11 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
mxUser.Contact = req.Matrix mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser) app.storage.SetMatrixKey(req.ID, mxUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix) msg := ""
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
} }
} }
if email, ok := app.storage.GetEmailsKey(req.ID); ok { if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@ -388,14 +393,11 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
email.Contact = req.Email email.Contact = req.Email
app.storage.SetEmailsKey(req.ID, email) app.storage.SetEmailsKey(req.ID, email)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email) msg := ""
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email if !req.Email {
msg = " not"
} }
} app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
} }
respondBool(200, true, gc) respondBool(200, true, gc)
@ -431,7 +433,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin) token, ok := app.telegram.TokenVerified(pin)
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) { if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
app.discord.DeleteVerifiedToken(pin) app.discord.DeleteVerifiedUser(pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -454,7 +456,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
} }
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.discord.UserVerified(pin) user, ok := app.discord.UserVerified(pin)
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) { if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
delete(app.discord.verifiedTokens, pin) delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -472,7 +474,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get] // @Router /invite/{invCode}/discord/invite [get]
// @tags Other // @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) { func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.InviteChannel.Name == "" { if app.discord.inviteChannelName == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -540,7 +542,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
func (app *appContext) MatrixCheckPIN(gc *gin.Context) { func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Printf(lm.InvalidInviteCode, code) app.debug.Println("Matrix: Invite code was invalid")
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
@ -548,12 +550,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin] user, ok := app.matrix.tokens[pin]
if !ok { if !ok {
app.debug.Printf(lm.InvalidPIN, pin) app.debug.Println("Matrix: PIN not found")
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
if user.User.UserID != userID { if user.User.UserID != userID {
app.debug.Printf(lm.UnauthorizedPIN, pin) app.debug.Println("Matrix: User ID of PIN didn't match")
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
@ -570,7 +572,6 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password." // @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post] // @Router /matrix/login [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) { func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO var req MatrixLoginDTO
@ -581,18 +582,18 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
} }
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password) token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGenerateToken, err) app.err.Printf("Matrix: Failed to generate token: %v", err)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
tempConfig, _ := ini.ShadowLoad(app.configPath) tempConfig, _ := ini.Load(app.configPath)
matrix := tempConfig.Section("matrix") matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true") matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver) matrix.Key("homeserver").SetValue(req.Homeserver)
matrix.Key("token").SetValue(token) matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username) matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil { if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf(lm.FailedWriting, app.configPath, err) app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -606,7 +607,6 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID." // @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post] // @Router /users/matrix [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) { func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO var req MatrixConnectUserDTO
@ -614,9 +614,9 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
if app.storage.GetMatrix() == nil { if app.storage.GetMatrix() == nil {
app.storage.deprecatedMatrix = matrixStore{} app.storage.deprecatedMatrix = matrixStore{}
} }
roomID, err := app.matrix.CreateRoom(req.UserID) roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
if err != nil { if err != nil {
app.err.Printf(lm.FailedCreateRoom, err) app.err.Printf("Matrix: Failed to create room: %v", err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -625,7 +625,9 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
RoomID: string(roomID), RoomID: string(roomID),
Lang: "en-us", Lang: "en-us",
Contact: true, Contact: true,
Encrypted: encrypted,
}) })
app.matrix.isEncrypted[roomID] = encrypted
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -636,7 +638,6 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param username path string true "username to search." // @Param username path string true "username to search."
// @Router /users/discord/{username} [get] // @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) { func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username") name := gc.Param("username")
@ -663,7 +664,6 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID." // @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post] // @Router /users/discord [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) { func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO var req DiscordConnectUserDTO
@ -677,25 +677,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.storage.SetDiscordKey(req.JellyfinID, user) app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -705,7 +687,6 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete] // @Router /users/discord [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) { func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -716,24 +697,6 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
return return
} */ } */
app.storage.DeleteDiscordKey(req.ID) app.storage.DeleteDiscordKey(req.ID)
// May not actually remove Discord ID, but should disable interaction.
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -742,7 +705,6 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete] // @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) { func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -753,23 +715,6 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
return return
} */ } */
app.storage.DeleteTelegramKey(req.ID) app.storage.DeleteTelegramKey(req.ID)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -778,7 +723,6 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete] // @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) { func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -789,15 +733,5 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
return return
} */ } */
app.storage.DeleteMatrixKey(req.ID) app.storage.DeleteMatrixKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc) respondBool(200, true, gc)
} }

View File

@ -2,71 +2,34 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
) )
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, error) { func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
jfUser, err := app.jf.UserByID(jfID, false) ombiUsers, code, err := app.ombi.GetUsers()
if err != nil { if err != nil || code != 200 {
return nil, err return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 {
return nil, code, err
} }
username := jfUser.Name username := jfUser.Name
email := "" email := ""
if e, ok := app.storage.GetEmailsKey(jfID); ok { if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr email = e.Addr
} }
user, err := app.ombi.getUser(username, email)
return user, err
}
func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, error) {
ombiUsers, err := ombi.GetUsers()
if err != nil {
return nil, err
}
for _, ombiUser := range ombiUsers { for _, ombiUser := range ombiUsers {
ombiAddr := "" ombiAddr := ""
if a, ok := ombiUser["emailAddress"]; ok && a != nil { if a, ok := ombiUser["emailAddress"]; ok && a != nil {
ombiAddr = a.(string) ombiAddr = a.(string)
} }
if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") { if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") {
return ombiUser, err return ombiUser, code, err
} }
} }
// Gets a generic "not found" type error return nil, 400, fmt.Errorf("Couldn't find user")
return nil, common.GenericErr(404, err)
}
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, err := ombi.GetUsers()
if err != nil {
return nil, err
}
for _, ombiUser := range ombiUsers {
if ombiUser["userName"].(string) == name {
uType, ok := ombiUser["userType"].(int)
if !ok { // Don't know if Ombi somehow allows duplicate usernames
continue
}
if serverType == mediabrowser.JellyfinServer && uType != 5 { // Jellyfin
continue
} else if uType != 3 && uType != 4 { // Emby
continue
}
return ombiUser, err
}
}
// Gets a generic "not found" type error
return nil, common.GenericErr(404, err)
} }
// @Summary Get a list of Ombi users. // @Summary Get a list of Ombi users.
@ -77,9 +40,10 @@ func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, e
// @Security Bearer // @Security Bearer
// @tags Ombi // @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) { func (app *appContext) OmbiUsers(gc *gin.Context) {
users, err := app.ombi.GetUsers() app.debug.Println("Ombi users requested")
if err != nil { users, status, err := app.ombi.GetUsers()
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err) if err != nil || status != 200 {
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err)
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
@ -106,16 +70,15 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) SetOmbiProfile(gc *gin.Context) { func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser var req ombiUser
gc.BindJSON(&req) gc.BindJSON(&req)
escapedProfileName := gc.Param("profile") profileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName) profile, ok := app.storage.GetProfileKey(profileName)
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
template, err := app.ombi.TemplateByID(req.ID) template, code, err := app.ombi.TemplateByID(req.ID)
if err != nil || len(template) == 0 { if err != nil || code != 200 || len(template) == 0 {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err) app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
@ -134,8 +97,7 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Ombi // @tags Ombi
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) { func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
escapedProfileName := gc.Param("profile") profileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName) profile, ok := app.storage.GetProfileKey(profileName)
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
@ -145,79 +107,3 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile) app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc) respondBool(204, true, gc)
} }
type OmbiWrapper struct {
*ombi.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (err error) {
for k, v := range profile {
switch v.(type) {
case map[string]interface{}, []interface{}:
user[k] = v
default:
if v != user[k] {
user[k] = v
}
}
}
err = ombi.ModifyUser(user)
return
}
func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
errors, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
var ombiUser map[string]interface{}
if err != nil {
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, err = ombi.getImportedUser(req.Username)
if err == nil {
// app.info.Println(lm.Ombi + " " + lm.UserExists)
profile.Ombi["password"] = req.Password
err = ombi.applyProfile(ombiUser, profile.Ombi)
if err != nil {
err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
}
} else {
if len(errors) != 0 {
err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", "))
}
return
}
}
ok = true
return
}
func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
var ombiUser map[string]interface{}
ombiUser, err = ombi.getUser(req.Username, req.Email)
if err != nil {
return
}
if discordEnabled || telegramEnabled {
dID := ""
tUser := ""
if discord != nil {
dID = discord.ID
}
if telegram != nil {
tUser = telegram.Username
}
var resp string
resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
}
return
}
}
return
}
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
}

View File

@ -1,48 +1,22 @@
package main package main
import ( import (
"fmt" "strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages" "github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
// @Summary Get the names of all available profile. // @Summary Get a list of profiles
// @Produce json
// @Success 200 {object} getProfileNamesDTO
// @Router /profiles/names [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfileNames(gc *gin.Context) {
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getProfileNamesDTO{
Profiles: profiles,
}
gc.JSON(200, resp)
}
// @Summary Get all available profiles, indexed by their names.
// @Produce json // @Produce json
// @Success 200 {object} getProfilesDTO // @Success 200 {object} getProfilesDTO
// @Router /profiles [get] // @Router /profiles [get]
// @Security Bearer // @Security Bearer
// @tags Profiles & Settings // @tags Profiles & Settings
func (app *appContext) GetProfiles(gc *gin.Context) { func (app *appContext) GetProfiles(gc *gin.Context) {
app.debug.Println("Profiles requested")
out := getProfilesDTO{ out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name, DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{}, Profiles: map[string]profileDTO{},
@ -55,7 +29,6 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
LibraryAccess: p.LibraryAccess, LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser, FromUser: p.FromUser,
Ombi: p.Ombi != nil, Ombi: p.Ombi != nil,
Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false, ReferralsEnabled: false,
} }
if referralsEnabled { if referralsEnabled {
@ -80,11 +53,10 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
func (app *appContext) SetDefaultProfile(gc *gin.Context) { func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{} req := profileChangeDTO{}
gc.BindJSON(&req) gc.BindJSON(&req)
app.info.Printf(lm.SetDefaultProfile, req.Name) app.info.Printf("Setting default profile to \"%s\"", req.Name)
if _, ok := app.storage.GetProfileKey(req.Name); !ok { if _, ok := app.storage.GetProfileKey(req.Name); !ok {
msg := fmt.Sprintf(lm.FailedGetProfile, req.Name) app.err.Printf("Profile not found: \"%s\"", req.Name)
app.err.Println(msg) respond(500, "Profile not found", gc)
respond(500, msg, gc)
return return
} }
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error { app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
@ -108,12 +80,13 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Profiles & Settings // @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) { func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested")
var req newProfileDTO var req newProfileDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
user, err := app.jf.UserByID(req.ID, false) user, status, err := app.jf.UserByID(req.ID, false)
if err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
@ -122,21 +95,17 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
Policy: user.Policy, Policy: user.Policy,
Homescreen: req.Homescreen, Homescreen: req.Homescreen,
} }
app.debug.Printf(lm.CreateProfileFromUser, user.Name) app.debug.Printf("Creating profile from user \"%s\"", user.Name)
if req.Homescreen { if req.Homescreen {
profile.Configuration = user.Configuration profile.Configuration = user.Configuration
profile.Displayprefs, err = app.jf.GetDisplayPreferences(req.ID) profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf(lm.FailedGetJellyfinDisplayPrefs, req.ID, err) app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err)
respond(500, "Couldn't get displayprefs", gc) respond(500, "Couldn't get displayprefs", gc)
return return
} }
} }
app.storage.SetProfileKey(req.Name, profile) app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {
app.discord.UpdateCommands()
}
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -159,41 +128,39 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
// @Produce json // @Produce json
// @Param profile path string true "name of profile to enable referrals for." // @Param profile path string true "name of profile to enable referrals for."
// @Param invite path string true "invite code to create referral template from." // @Param invite path string true "invite code to create referral template from."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse // @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post] // @Router /profiles/referral/{profile}/{invite} [post]
// @Security Bearer // @Security Bearer
// @tags Profiles & Settings // @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) { func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile") profileName := gc.Param("profile")
invCode := gc.Param("invite") invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode) inv, ok := app.storage.GetInvitesKey(invCode)
if !ok { if !ok {
respond(400, "Invalid invite code", gc) respond(400, "Invalid invite code", gc)
app.err.Printf(lm.InvalidInviteCode, invCode) app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName)
return return
} }
profile, ok := app.storage.GetProfileKey(profileName) profile, ok := app.storage.GetProfileKey(profileName)
if !ok { if !ok {
respond(400, "Invalid profile", gc) respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName) app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName)
return return
} }
// Generate new code for referral template // Generate new code for referral template
inv.Code = GenerateInviteCode() inv.Code = shortuuid.New()
expiryDelta := inv.ValidTill.Sub(inv.Created) // make sure code doesn't begin with number
inv.Created = time.Now() _, err := strconv.Atoi(string(inv.Code[0]))
if useExpiry { for err == nil {
inv.ValidTill = inv.Created.Add(expiryDelta) inv.Code = shortuuid.New()
} else { _, err = strconv.Atoi(string(inv.Code[0]))
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
} }
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
inv.IsReferral = true inv.IsReferral = true
inv.UseReferralExpiry = useExpiry
// Since this is a template for multiple users, ReferrerJellyfinID is not set. // Since this is a template for multiple users, ReferrerJellyfinID is not set.
// inv.ReferrerJellyfinID = ... // inv.ReferrerJellyfinID = ...

View File

@ -1,16 +1,14 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -29,9 +27,9 @@ func (app *appContext) MyDetails(gc *gin.Context) {
Id: gc.GetString("jfId"), Id: gc.GetString("jfId"),
} }
user, err := app.jf.UserByID(resp.Id, false) user, status, err := app.jf.UserByID(resp.Id, false)
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
respond(500, "Failed to get user", gc) respond(500, "Failed to get user", gc)
return return
} }
@ -135,9 +133,8 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
func (app *appContext) LogoutUser(gc *gin.Context) { func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh") cookie, err := gc.Cookie("user-refresh")
if err != nil { if err != nil {
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err) app.debug.Printf("Couldn't get cookies: %s", err)
app.debug.Println(msg) respond(500, "Couldn't fetch cookies", gc)
respond(500, msg, gc)
return return
} }
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
@ -177,21 +174,21 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
} }
token, err := jwt.Parse(key, checkToken) token, err := jwt.Parse(key, checkToken)
if err != nil { if err != nil {
app.err.Printf(lm.FailedParseJWT, err) app.err.Printf("Failed to parse key: %s", err)
fail() fail()
// respond(500, "unknownError", gc) // respond(500, "unknownError", gc)
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
app.err.Println(lm.FailedCastJWT) app.err.Printf("Failed to parse key: %s", err)
fail() fail()
// respond(500, "unknownError", gc) // respond(500, "unknownError", gc)
return return
} }
expiry := time.Unix(int64(claims["exp"].(float64)), 0) expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Println(lm.InvalidJWT) app.err.Printf("Invalid key")
fail() fail()
// respond(400, "invalidKey", gc) // respond(400, "invalidKey", gc)
return return
@ -204,18 +201,26 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
gc.Redirect(http.StatusSeeOther, "/my/account") gc.Redirect(http.StatusSeeOther, "/my/account")
return return
} else if target == UserEmailChange { } else if target == UserEmailChange {
app.modifyEmail(id, claims["email"].(string)) emailStore, ok := app.storage.GetEmailsKey(id)
if !ok {
emailStore = EmailAddress{
Contact: true,
}
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = claims["email"].(string)
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.info.Println("Email list modified")
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
}, gc, true)
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, "/my/account") gc.Redirect(http.StatusSeeOther, "/my/account")
return return
} }
@ -234,6 +239,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
func (app *appContext) ModifyMyEmail(gc *gin.Context) { func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO var req ModifyMyEmailDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') { if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc) respond(400, "Invalid Email Address", gc)
return return
@ -253,26 +259,26 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil { if err != nil {
app.err.Printf(lm.FailedSignJWT, err) app.err.Printf("Failed to generate confirmation token: %v", err)
respond(500, "errorUnknown", gc) respond(500, "errorUnknown", gc)
return return
} }
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
user, err := app.jf.UserByID(id, false) user, status, err := app.jf.UserByID(id, false)
name := "" name := ""
if err == nil { if status == 200 && err == nil {
name = user.Name name = user.Name
} }
app.debug.Printf(lm.EmailConfirmationRequired, id) app.debug.Printf("%s: Email confirmation required", id)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false) msg, err := app.email.constructConfirmation("", name, key, app, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err) app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err) app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
} else { } else {
app.err.Printf(lm.SentConfirmationEmail, id, req.Email) app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
} }
return return
} }
@ -292,7 +298,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags User Page // @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.InviteChannel.Name == "" { if app.discord.inviteChannelName == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -340,7 +346,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedToken(pin) app.discord.DeleteVerifiedUser(pin)
if !ok { if !ok {
respondBool(200, false, gc) respondBool(200, false, gc)
return return
@ -355,23 +361,6 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
dcUser.Contact = existingUser.Contact dcUser.Contact = existingUser.Contact
} }
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser) app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -410,23 +399,6 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
tgUser.Contact = existingUser.Contact tgUser.Contact = existingUser.Contact
} }
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser) app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -479,12 +451,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin] user, ok := app.matrix.tokens[pin]
if !ok { if !ok {
app.debug.Printf(lm.InvalidPIN, pin) app.debug.Println("Matrix: PIN not found")
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
if user.User.UserID != userID { if user.User.UserID != userID {
app.debug.Printf(lm.UnauthorizedPIN, pin) app.debug.Println("Matrix: User ID of PIN didn't match")
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
@ -498,16 +470,6 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
} }
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser) app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
delete(app.matrix.tokens, pin) delete(app.matrix.tokens, pin)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -520,23 +482,6 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
// @Tags User Page // @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId")) app.storage.DeleteDiscordKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -548,23 +493,6 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
// @Tags User Page // @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId")) app.storage.DeleteTelegramKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -576,16 +504,6 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
// @Tags User Page // @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
app.storage.DeleteMatrixKey(gc.GetString("jfId")) app.storage.DeleteMatrixKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -603,11 +521,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
cancel := time.AfterFunc(1*time.Second, func() { cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true timerWait <- true
}) })
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address") address := gc.Param("address")
if address == "" { if address == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop() cancel.Stop()
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -615,9 +531,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR var pwr InternalPWR
var err error var err error
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed) jfUser, ok := app.ReverseUserSearch(address)
if !ok { if !ok {
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results") app.debug.Printf("Ignoring PWR request: User not found")
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
@ -627,7 +543,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
} }
pwr, err = app.GenInternalReset(jfUser.ID) pwr, err = app.GenInternalReset(jfUser.ID)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf("Failed to get user from Jellyfin: %v", err)
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
return return
@ -648,16 +564,16 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}, app, false, }, app, false,
) )
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err) app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
return return
} }
return return
} else if err := app.sendByID(msg, jfUser.ID); err != nil { } else if err := app.sendByID(msg, jfUser.ID); err != nil {
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err) app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
} else { } else {
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?") app.info.Printf("Sent password reset message to \"%s\"", address)
} }
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
@ -684,50 +600,42 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
validation := app.validator.validate(req.New) validation := app.validator.validate(req.New)
for _, val := range validation { for _, val := range validation {
if !val { if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation) gc.JSON(400, validation)
return return
} }
} }
user, err := app.jf.UserByID(gc.GetString("jfId"), false) user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err) app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
// Authenticate as user to confirm old password. // Authenticate as user to confirm old password.
user, err = app.authJf.Authenticate(user.Name, req.Old) user, status, err = app.authJf.Authenticate(user.Name, req.Old)
if err != nil { if status != 200 || err != nil {
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New) status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if err != nil { if (status != 200 && status != 204) || err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityChangePassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() { func() {
ombiUser, err := app.getOmbiUser(gc.GetString("jfId")) ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err) app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
return return
} }
ombiUser["password"] = req.New ombiUser["password"] = req.New
err = app.ombi.ModifyUser(ombiUser) status, err = app.ombi.ModifyUser(ombiUser)
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err) app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
return return
} }
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"]) app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
}() }()
} }
cookie, err := gc.Cookie("user-refresh") cookie, err := gc.Cookie("user-refresh")
@ -735,7 +643,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true) gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else { } else {
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err) app.debug.Printf("Couldn't get cookies: %s", err)
} }
respondBool(204, true, gc) respondBool(204, true, gc)
} }
@ -760,40 +668,34 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// Since this key is shared between users in a profile, we make a copy. // Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv) err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil || user.ReferralTemplateKey == "" { if !ok || err != nil {
app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err) app.debug.Printf("Ignoring referral request, couldn't find template.")
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
inv.Code = GenerateInviteCode() inv.Code = shortuuid.New()
expiryDelta := inv.ValidTill.Sub(inv.Created) // make sure code doesn't begin with number
inv.Created = time.Now() _, err := strconv.Atoi(string(inv.Code[0]))
if inv.UseReferralExpiry { for err == nil {
inv.ValidTill = inv.Created.Add(expiryDelta) inv.Code = shortuuid.New()
} else { _, err = strconv.Atoi(string(inv.Code[0]))
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
} }
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
inv.IsReferral = true inv.IsReferral = true
inv.ReferrerJellyfinID = gc.GetString("jfId") inv.ReferrerJellyfinID = gc.GetString("jfId")
app.storage.SetInvitesKey(inv.Code, inv) app.storage.SetInvitesKey(inv.Code, inv)
} else if time.Now().After(inv.ValidTill) { } else if time.Now().After(inv.ValidTill) {
// 3. We found an invite for us, but it's expired. // 3. We found an invite for us, but it's expired.
// We delete it from storage, and put it back with a fresh code and expiry. // We delete it from storage, and put it back with a fresh code and expiry.
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code) app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry { inv.Code = shortuuid.New()
app.debug.Printf(lm.DeleteOldReferral, inv.Code) // make sure code doesn't begin with number
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) _, err := strconv.Atoi(string(inv.Code[0]))
if ok { for err == nil {
user.ReferralTemplateKey = "" inv.Code = shortuuid.New()
app.storage.SetEmailsKey(gc.GetString("jfId"), user) _, err = strconv.Atoi(string(inv.Code[0]))
} }
app.debug.Printf("Ignoring referral request, expired.")
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.RenewOldReferral, inv.Code)
inv.Code = GenerateInviteCode()
inv.Created = time.Now() inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv) app.storage.SetInvitesKey(inv.Code, inv)
@ -803,6 +705,5 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
RemainingUses: inv.RemainingUses, RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit, NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(), Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
}) })
} }

File diff suppressed because it is too large Load Diff

270
api.go
View File

@ -1,16 +1,12 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
@ -117,7 +113,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
var req ResetPasswordDTO var req ResetPasswordDTO
gc.BindJSON(&req) gc.BindJSON(&req)
validation := app.validator.validate(req.Password) validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true valid := true
for _, val := range validation { for _, val := range validation {
if !val { if !val {
@ -125,42 +120,35 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
} }
if !valid || req.PIN == "" { if !valid || req.PIN == "" {
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.InvalidPassword) // 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
gc.JSON(400, validation) gc.JSON(400, validation)
return return
} }
isInternal := false isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
respond(400, "errorCaptcha", gc)
return
}
var userID, username string var userID, username string
if reset, ok := app.internalPWRs[req.PIN]; ok { if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true isInternal = true
if time.Now().After(reset.Expiry) { if time.Now().After(reset.Expiry) {
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, reset.PIN)) app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN)
respondBool(401, false, gc) respondBool(401, false, gc)
delete(app.internalPWRs, req.PIN) delete(app.internalPWRs, req.PIN)
return return
} }
userID = reset.ID userID = reset.ID
username = reset.Username username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
err := app.jf.ResetPasswordAdmin(userID) if !(status == 200 || status == 204) || err != nil {
if err != nil { app.err.Printf("Password Reset failed (%d): %v", status, err)
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err) respondBool(status, false, gc)
respondBool(500, false, gc)
return return
} }
delete(app.internalPWRs, req.PIN) delete(app.internalPWRs, req.PIN)
} else { } else {
resp, err := app.jf.ResetPassword(req.PIN) resp, status, err := app.jf.ResetPassword(req.PIN)
if err != nil || !resp.Success { if status != 200 || err != nil || !resp.Success {
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err) app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(500, false, gc) respondBool(status, false, gc)
return return
} }
if req.Password == "" || len(resp.UsersReset) == 0 { if req.Password == "" || len(resp.UsersReset) == 0 {
@ -169,147 +157,216 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
username = resp.UsersReset[0] username = resp.UsersReset[0]
} }
var user mediabrowser.User var user mediabrowser.User
var status int
var err error var err error
if isInternal { if isInternal {
user, err = app.jf.UserByID(userID, false) user, status, err = app.jf.UserByID(userID, false)
} else { } else {
user, err = app.jf.UserByName(username, false) user, status, err = app.jf.UserByName(username, false)
} }
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err) app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityResetPassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
prevPassword := req.PIN prevPassword := req.PIN
if isInternal { if isInternal {
prevPassword = "" prevPassword = ""
} }
err = app.jf.SetPassword(user.ID, prevPassword, req.Password) status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
if err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, user.ID, err) app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
// This makes no sense so has been commented out. // Silently fail for changing ombi passwords
// It probably did at some point in the past. if status != 200 || err != nil {
/* Silently fail for changing ombi passwords app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
if (status != 200 && status != 204) || err != nil {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} */ }
ombiUser, err := app.getOmbiUser(user.ID) ombiUser, status, err := app.getOmbiUser(user.ID)
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err) app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
ombiUser["password"] = req.Password ombiUser["password"] = req.Password
err = app.ombi.ModifyUser(ombiUser) status, err = app.ombi.ModifyUser(ombiUser)
if err != nil { if status != 200 || err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, user.ID, err) app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
app.debug.Printf(lm.ChangePassword, lm.Ombi, user.ID) app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
} }
respondBool(200, true, gc) respondBool(200, true, gc)
} }
// @Summary Get jfa-go configuration. // @Summary Get jfa-go configuration.
// @Produce json // @Produce json
// @Success 200 {object} common.Config "Uses the same format as config-base.json" // @Success 200 {object} settings "Uses the same format as config-base.json"
// @Router /config [get] // @Router /config [get]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) { func (app *appContext) GetConfig(gc *gin.Context) {
if discordEnabled { app.info.Println("Config requested")
app.PatchConfigDiscordRoles() resp := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
fl := resp.Sections["ui"].Settings["language-form"]
fl.Options = formOptions
fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us")
pwrOptions := app.storage.lang.PasswordReset.getOptions()
pl := resp.Sections["password_resets"].Settings["language"]
pl.Options = pwrOptions
pl.Value = app.config.Section("password_resets").Key("language").MustString("en-us")
adminOptions := app.storage.lang.Admin.getOptions()
al := resp.Sections["ui"].Settings["language-admin"]
al.Options = adminOptions
al.Value = app.config.Section("ui").Key("language-admin").MustString("en-us")
emailOptions := app.storage.lang.Email.getOptions()
el := resp.Sections["email"].Settings["language"]
el.Options = emailOptions
el.Value = app.config.Section("email").Key("language").MustString("en-us")
telegramOptions := app.storage.lang.Email.getOptions()
tl := resp.Sections["telegram"].Settings["language"]
tl.Options = telegramOptions
tl.Value = app.config.Section("telegram").Key("language").MustString("en-us")
if updater == "" {
delete(resp.Sections, "updates")
for i, v := range resp.Order {
if v == "updates" {
resp.Order = append(resp.Order[:i], resp.Order[i+1:]...)
break
} }
gc.JSON(200, app.patchedConfig) }
}
if PLATFORM == "windows" {
delete(resp.Sections["smtp"].Settings, "ssl_cert")
for i, v := range resp.Sections["smtp"].Order {
if v == "ssl_cert" {
sect := resp.Sections["smtp"]
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
resp.Sections["smtp"] = sect
}
}
}
if !MatrixE2EE() {
delete(resp.Sections["matrix"].Settings, "encryption")
for i, v := range resp.Sections["matrix"].Order {
if v == "encryption" {
sect := resp.Sections["matrix"]
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
resp.Sections["matrix"] = sect
}
}
}
for sectName, section := range resp.Sections {
for settingName, setting := range section.Settings {
val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName]
switch setting.Type {
case "text", "email", "select", "password", "note":
s.Value = val.MustString("")
case "number":
s.Value = val.MustInt(0)
case "bool":
s.Value = val.MustBool(false)
}
resp.Sections[sectName].Settings[settingName] = s
}
}
if discordEnabled {
r, err := app.discord.ListRoles()
if err == nil {
roles := make([][2]string, len(r)+1)
roles[0] = [2]string{"", "None"}
for i, role := range r {
roles[i+1] = role
}
s := resp.Sections["discord"].Settings["apply_role"]
s.Options = roles
resp.Sections["discord"].Settings["apply_role"] = s
}
}
resp.Sections["ui"].Settings["language-form"] = fl
resp.Sections["ui"].Settings["language-admin"] = al
resp.Sections["email"].Settings["language"] = el
resp.Sections["password_resets"].Settings["language"] = pl
resp.Sections["telegram"].Settings["language"] = tl
resp.Sections["discord"].Settings["language"] = tl
resp.Sections["matrix"].Settings["language"] = tl
// if setting := resp.Sections["invite_emails"].Settings["url_base"]; setting.Value == "" {
// setting.Value = strings.TrimSuffix(resp.Sections["password_resets"].Settings["url_base"].Value.(string), "/invite")
// resp.Sections["invite_emails"].Settings["url_base"] = setting
// }
// if setting := resp.Sections["password_resets"].Settings["url_base"]; setting.Value == "" {
// setting.Value = strings.TrimSuffix(resp.Sections["invite_emails"].Settings["url_base"].Value.(string), "/invite")
// resp.Sections["password_resets"].Settings["url_base"] = setting
// }
gc.JSON(200, resp)
} }
// @Summary Modify app config. // @Summary Modify app config.
// @Produce json // @Produce json
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)." // @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings."
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /config [post] // @Router /config [post]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) ModifyConfig(gc *gin.Context) { func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req configDTO var req configDTO
gc.BindJSON(&req) gc.BindJSON(&req)
// Load a new config, as we set various default values in app.config that shouldn't be stored. // Load a new config, as we set various default values in app.config that shouldn't be stored.
tempConfig, _ := ini.ShadowLoad(app.configPath) tempConfig, _ := ini.Load(app.configPath)
for _, section := range app.configBase.Sections { for section, settings := range req {
ns, ok := req[section.Section] if section != "restart-program" {
if !ok { _, err := tempConfig.GetSection(section)
continue
}
newSection := ns.(map[string]any)
iniSection, err := tempConfig.GetSection(section.Section)
if err != nil { if err != nil {
iniSection, _ = tempConfig.NewSection(section.Section) tempConfig.NewSection(section)
} }
for _, setting := range section.Settings { for setting, value := range settings.(map[string]interface{}) {
newValue, ok := newSection[setting.Setting] if section == "email" && setting == "method" && value == "disabled" {
if !ok { value = ""
continue }
if (section == "discord" || section == "matrix") && setting == "language" {
tempConfig.Section("telegram").Key("language").SetValue(value.(string))
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string))
} }
// Patch disabled to actually be an empty string
if section.Section == "email" && setting.Setting == "method" && newValue == "disabled" {
newValue = ""
}
// Copy language preference for chatbots to root one in "telegram"
if (section.Section == "discord" || section.Section == "matrix") && setting.Setting == "language" {
iniSection.Key("language").SetValue(newValue.(string))
} else if setting.Type == common.ListType {
splitValues := strings.Split(newValue.(string), "|")
// Delete the key first to get rid of any shadow values
iniSection.DeleteKey(setting.Setting)
for i, v := range splitValues {
if i == 0 {
iniSection.Key(setting.Setting).SetValue(v)
} else {
iniSection.Key(setting.Setting).AddShadow(v)
}
}
} else if newValue.(string) != iniSection.Key(setting.Setting).MustString("") {
iniSection.Key(setting.Setting).SetValue(newValue.(string))
} }
} }
} }
tempConfig.Section("").Key("first_run").SetValue("false") tempConfig.Section("").Key("first_run").SetValue("false")
if err := tempConfig.SaveTo(app.configPath); err != nil { if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf(lm.FailedWriting, app.configPath, err) app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
respond(500, err.Error(), gc) respond(500, err.Error(), gc)
return return
} }
app.info.Printf(lm.ModifyConfig, app.configPath) app.debug.Println("Config saved")
gc.JSON(200, map[string]bool{"success": true}) gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) { if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart() app.info.Println("Restarting...")
if TRAY {
TRAYRESTART <- true
} else {
RESTART <- true
}
// Safety Sleep (Ensure shutdown tasks get done)
time.Sleep(time.Second)
} }
app.loadConfig() app.loadConfig()
// Patch new settings for next GetConfig
app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python. // Reinitialize password validator on config change, as opposed to every applicable request like in python.
if _, ok := req["password_validation"]; ok { if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{ validatorConf := ValidatorConf{
"length": app.config.Section("password_validation").Key("min_length").MustInt(0), "length": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0), "uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
@ -349,13 +406,12 @@ func (app *appContext) CheckUpdate(gc *gin.Context) {
// @tags Configuration // @tags Configuration
func (app *appContext) ApplyUpdate(gc *gin.Context) { func (app *appContext) ApplyUpdate(gc *gin.Context) {
if !app.update.CanUpdate { if !app.update.CanUpdate {
app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual) respond(400, "Update is manual", gc)
respond(400, lm.UpdateManual, gc)
return return
} }
err := app.update.update() err := app.update.update()
if err != nil { if err != nil {
app.err.Printf(lm.FailedApplyUpdate, err) app.err.Printf("Failed to apply update: %v", err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -377,9 +433,8 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
func (app *appContext) Logout(gc *gin.Context) { func (app *appContext) Logout(gc *gin.Context) {
cookie, err := gc.Cookie("refresh") cookie, err := gc.Cookie("refresh")
if err != nil { if err != nil {
msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err) app.debug.Printf("Couldn't get cookies: %s", err)
app.debug.Println(msg) respond(500, "Couldn't fetch cookies", gc)
respond(500, msg, gc)
return return
} }
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
@ -452,7 +507,11 @@ func (app *appContext) ServeLang(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Other // @tags Other
func (app *appContext) restart(gc *gin.Context) { func (app *appContext) restart(gc *gin.Context) {
app.Restart() app.info.Println("Restarting...")
err := app.Restart()
if err != nil {
app.err.Printf("Couldn't restart, try restarting manually: %v", err)
}
} }
// @Summary Returns the last 100 lines of the log. // @Summary Returns the last 100 lines of the log.
@ -466,7 +525,6 @@ func (app *appContext) GetLog(gc *gin.Context) {
// no need to syscall.exec anymore! // no need to syscall.exec anymore!
func (app *appContext) Restart() error { func (app *appContext) Restart() error {
app.info.Println(lm.Restarting)
if TRAY { if TRAY {
TRAYRESTART <- true TRAYRESTART <- true
} else { } else {

View File

@ -23,7 +23,6 @@ func (app *appContext) loadArgs(firstCall bool) {
HOST = flag.String("host", "", "alternate address to host web ui on.") HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.") PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND") flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.") DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.") PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html") SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@ -42,9 +41,6 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF { if *PPROF {
os.Setenv("PPROF", "1") os.Setenv("PPROF", "1")
} }
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
} }
if os.Getenv("SWAGGER") == "1" { if os.Getenv("SWAGGER") == "1" {

111
auth.go
View File

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@ -10,7 +9,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
@ -20,31 +18,10 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24 REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
) )
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.info.Println(out)
}
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.debug.Println(out)
}
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.err.Println(out)
}
func (app *appContext) webAuth() gin.HandlerFunc { func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate return app.authenticate
} }
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens. // CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string, admin bool) (string, string, error) { func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string var token, refresh string
@ -76,26 +53,32 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
ok = false ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" { if header[0] != "Bearer" {
app.authLog(lm.InvalidAuthHeader) app.debug.Println("Invalid authorization header")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
token, err := jwt.Parse(string(header[1]), checkToken) token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil { if err != nil {
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err)) app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
claims, ok = token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
if !ok { if !ok {
app.authLog(lm.FailedCastJWT) app.debug.Println("Invalid JWT")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.authLog(lm.InvalidJWT) app.debug.Printf("Auth denied: Invalid token")
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string)) // app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
ok = false ok = false
@ -113,7 +96,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
} }
isAdminToken := claims["admin"].(bool) isAdminToken := claims["admin"].(bool)
if !isAdminToken { if !isAdminToken {
app.authLog(lm.NonAdminToken) app.debug.Printf("Auth denied: Token was not for admin access")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -128,13 +111,14 @@ func (app *appContext) authenticate(gc *gin.Context) {
} }
} }
if !match { if !match {
app.authLog(fmt.Sprintf(lm.NonAdminUser, userID)) app.debug.Printf("Couldn't find user ID \"%s\"", userID)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
gc.Set("jfId", jfID) gc.Set("jfId", jfID)
gc.Set("userId", userID) gc.Set("userId", userID)
gc.Set("userMode", false) gc.Set("userMode", false)
app.debug.Println("Auth succeeded")
gc.Next() gc.Next()
} }
@ -149,7 +133,7 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else. Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
} }
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) { func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1]) auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2) creds := strings.SplitN(string(auth), ":", 2)
@ -157,7 +141,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1] password = creds[1]
ok = false ok = false
if username == "" || password == "" { if username == "" || password == "" {
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass)) app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -165,21 +149,21 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
return return
} }
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) { func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
ok = false ok = false
user, err := app.authJf.Authenticate(username, password) user, status, err := app.authJf.Authenticate(username, password)
if err != nil { if status != 200 || err != nil {
if errors.Is(err, mediabrowser.ErrUnauthorized{}) { if status == 401 || status == 400 {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass)) app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
if errors.Is(err, mediabrowser.ErrForbidden{}) { if status == 403 {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled)) app.info.Println("Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc) respond(403, "yourAccountWasDisabled", gc)
return return
} }
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, err)) app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
respond(500, "Jellyfin error", gc) respond(500, "Jellyfin error", gc)
return return
} }
@ -196,8 +180,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth // @tags Auth
// @Security getTokenAuth // @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) { func (app *appContext) getTokenLogin(gc *gin.Context) {
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt)) app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, false) username, password, ok := app.decodeValidateLoginHeader(gc)
if !ok { if !ok {
return return
} }
@ -206,17 +190,18 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
for _, user := range app.adminUsers { for _, user := range app.adminUsers {
if user.Username == username && user.Password == password { if user.Username == username && user.Password == password {
match = true match = true
app.debug.Println("Found existing user")
userID = user.UserID userID = user.UserID
break break
} }
} }
if !app.jellyfinLogin && !match { if !app.jellyfinLogin && !match {
app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass)) app.info.Println("Auth denied: Invalid username/password")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
if !match { if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc, false) user, ok := app.validateJellyfinCredentials(username, password, gc)
if !ok { if !ok {
return return
} }
@ -229,7 +214,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
} }
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin { if !accountsAdmin {
app.authLog(fmt.Sprintf(lm.NonAdminUser, username)) app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -239,20 +224,16 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
newUser := User{ newUser := User{
UserID: userID, UserID: userID,
} }
app.debug.Printf(lm.GenerateToken, username) app.debug.Printf("Token generated for user \"%s\"", username)
app.adminUsers = append(app.adminUsers, newUser) app.adminUsers = append(app.adminUsers, newUser)
} }
token, refresh, err := CreateToken(userID, jfID, true) token, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGenerateToken, err) app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
// host := gc.Request.URL.Hostname() gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
host := app.ExternalDomain
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token}) gc.JSON(200, getTokenDTO{token})
} }
@ -260,29 +241,35 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
ok = false ok = false
cookie, err := gc.Cookie(cookieName) cookie, err := gc.Cookie(cookieName)
if err != nil || cookie == "" { if err != nil || cookie == "" {
app.authLog(fmt.Sprintf(lm.FailedGetCookies, cookieName, err)) app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
respond(400, "Couldn't get token", gc) respond(400, "Couldn't get token", gc)
return return
} }
for _, token := range app.invalidTokens { for _, token := range app.invalidTokens {
if cookie == token { if cookie == token {
app.authLog(lm.LocallyInvalidatedJWT) app.debug.Println("getTokenRefresh: Invalid token")
respond(401, lm.InvalidJWT, gc) respond(401, "Invalid token", gc)
return return
} }
} }
token, err := jwt.Parse(cookie, checkToken) token, err := jwt.Parse(cookie, checkToken)
if err != nil { if err != nil {
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err)) app.debug.Println("getTokenRefresh: Invalid token")
respond(400, lm.InvalidJWT, gc) respond(400, "Invalid token", gc)
return return
} }
claims, ok = token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.authLog(lm.InvalidJWT) app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
respond(401, lm.InvalidJWT, gc) respond(401, "Invalid token", gc)
ok = false ok = false
return return
} }
@ -297,7 +284,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get] // @Router /token/refresh [get]
// @tags Auth // @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) { func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh)) app.debug.Println("Token requested (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh") claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok { if !ok {
return return
@ -306,12 +293,10 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
jfID := claims["jfid"].(string) jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID, true) jwt, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGenerateToken, err) app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
// host := gc.Request.URL.Hostname() gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
host := app.ExternalDomain
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt}) gc.JSON(200, getTokenDTO{jwt})
} }

View File

@ -1,175 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
BACKUP_PREFIX = "jfa-go-db-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type BackupList struct {
files []os.DirEntry
dates []time.Time
count int
}
func (bl BackupList) Len() int { return len(bl.files) }
func (bl BackupList) Swap(i, j int) {
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
}
func (bl BackupList) Less(i, j int) bool {
// Push non-backup files to the end of the array,
// Since they didn't have a date parsed.
if bl.dates[i].IsZero() {
return false
}
if bl.dates[j].IsZero() {
return true
}
// Sort by oldest first
return bl.dates[j].After(bl.dates[i])
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf(lm.FailedCreateDir, path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf(lm.FailedReading, path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
backups.count = 0
for i, item := range items {
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if err != nil {
app.debug.Printf(lm.FailedParseTime, err)
continue
}
backups.dates[i] = t
backups.count++
}
return backups
}
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
sort.Sort(backups)
for _, item := range backups.files[:toDelete] {
fullpath := filepath.Join(path, item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
return
}
app.debug.Printf(lm.DeleteOldBackup, fullpath)
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf(lm.FailedOpen, fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf(lm.FailedCreateBackup, err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf(lm.FailedStat, fullpath, err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
app.debug.Printf(lm.CreateBackup, fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
}
app.info.Printf(lm.MoveOldDB, oldPath)
app.ConnectDB()
defer app.storage.db.Close()
f, err := os.Open(LOADBAK)
if err != nil {
app.err.Fatalf(lm.FailedOpen, LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
}
app.info.Printf(lm.RestoreDB, LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.makeBackup()
},
)
d.Name("Backup")
return d
}

View File

@ -1,18 +1,8 @@
package common package common
import ( import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"net/url"
"strings"
lm "github.com/hrfee/jfa-go/logmessages"
) )
// TimeoutHandler recovers from an http timeout or panic. // TimeoutHandler recovers from an http timeout or panic.
@ -22,7 +12,7 @@ type TimeoutHandler func()
func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler { func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
return func() { return func() {
if r := recover(); r != nil { if r := recover(); r != nil {
out := fmt.Sprintf(lm.FailedAuth, name, addr, 0, lm.TimedOut) out := fmt.Sprintf("Failed to authenticate with %s @ \"%s\": Timed out", name, addr)
if noFail { if noFail {
log.Print(out) log.Print(out)
} else { } else {
@ -31,120 +21,3 @@ func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
} }
} }
} }
// most 404 errors are from UserNotFound, so this generic error doesn't really need any detail.
type ErrNotFound error
type ErrUnauthorized struct{}
func (err ErrUnauthorized) Error() string {
return lm.Unauthorized
}
type ErrForbidden struct{}
func (err ErrForbidden) Error() string {
return lm.Forbidden
}
var (
NotFound ErrNotFound = errors.New(lm.NotFound)
)
type ErrUnknown struct {
code int
}
func (err ErrUnknown) Error() string {
msg := fmt.Sprintf(lm.FailedGenericWithCode, err.code)
return msg
}
// GenericErr returns an error appropriate to the given HTTP status (or actual error, if given).
func GenericErr(status int, err error) error {
if err != nil {
return err
}
switch status {
case 200, 204, 201:
return nil
case 401, 400:
return ErrUnauthorized{}
case 404:
return NotFound
case 403:
return ErrForbidden{}
default:
return ErrUnknown{code: status}
}
}
type ConfigurableTransport interface {
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
SetTransport(t *http.Transport)
}
// Stripped down-ish version of rough http request function used in most of the API clients.
func Req(httpClient *http.Client, timeoutHandler TimeoutHandler, mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range headers {
req.Header.Add(name, value)
}
resp, err := httpClient.Do(req)
if resp == nil {
return "", 0, err
}
err = GenericErr(resp.StatusCode, err)
if timeoutHandler != nil {
defer timeoutHandler()
}
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if err != nil {
var msg any
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg != nil {
err = fmt.Errorf("got %d: %+v", resp.StatusCode, msg)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}

View File

@ -1,62 +0,0 @@
package common
type SectionMeta struct {
Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
Description string `json:"description" yaml:"description"`
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
}
type Option [2]string
type SettingType string
var (
BoolType SettingType = "bool"
SelectType SettingType = "select"
TextType SettingType = "text"
PasswordType SettingType = "password"
NumberType SettingType = "number"
NoteType SettingType = "note"
EmailType SettingType = "email"
ListType SettingType = "list"
)
type Setting struct {
Setting string `json:"setting" yaml:"setting" example:"my_setting"`
Name string `json:"name" yaml:"name" example:"My Setting"`
Description string `json:"description" yaml:"description"`
Required bool `json:"required" yaml:"required"`
RequiresRestart bool `json:"requires_restart" yaml:"requires_restart"`
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
Type SettingType `json:"type" yaml:"type"` // Type (string, number, bool, etc.)
Value any `json:"value" yaml:"value"`
Options []Option `json:"options,omitempty" yaml:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
Style string `json:"style,omitempty" yaml:"style,omitempty"`
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
}
type Section struct {
Section string `json:"section" yaml:"section" example:"my_section"`
Meta SectionMeta `json:"meta" yaml:"meta"`
Settings []Setting `json:"settings" yaml:"settings"`
}
type Config struct {
Sections []Section `json:"sections" yaml:"sections"`
}
func (c *Config) removeSection(section string) {
for i, v := range c.Sections {
if v.Section == section {
c.Sections = append(c.Sections[:i], c.Sections[i+1:]...)
break
}
}
}

View File

@ -1,7 +1,3 @@
module github.com/hrfee/jfa-go/common module github.com/hrfee/jfa-go/common
replace github.com/hrfee/jfa-go/logmessages => ../logmessages go 1.15
go 1.18
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a

161
config.go
View File

@ -3,16 +3,12 @@ package main
import ( import (
"fmt" "fmt"
"io/fs" "io/fs"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
@ -37,7 +33,7 @@ func (app *appContext) MustSetValue(section, key, val string) {
func (app *appContext) loadConfig() error { func (app *appContext) loadConfig() error {
var err error var err error
app.config, err = ini.ShadowLoad(app.configPath) app.config, err = ini.Load(app.configPath)
if err != nil { if err != nil {
return err return err
} }
@ -57,23 +53,7 @@ func (app *appContext) loadConfig() error {
for _, key := range []string{"matrix_sql"} { for _, key := range []string{"matrix_sql"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db")))) app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
} }
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
if app.URLBase == "/invite" || app.URLBase == "/accounts" || app.URLBase == "/settings" || app.URLBase == "/activity" {
app.err.Printf(lm.BadURLBase, app.URLBase)
}
app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.ExternalURI, app.URLBase) {
app.err.Println(lm.NoURLSuffix)
}
if app.ExternalURI == "" {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
u, err := url.Parse(app.ExternalURI)
if err == nil {
app.ExternalDomain = u.Hostname()
}
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false))) app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html") app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
@ -96,10 +76,6 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("smtp", "hello_hostname", "localhost") app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true") app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start") sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!")) app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
@ -125,11 +101,6 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html") app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt") app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
app.MustSetValue("email", "collect", "true")
app.MustSetValue("matrix", "topic", "Jellyfin notifications") app.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("matrix", "show_on_reg", "true") app.MustSetValue("matrix", "show_on_reg", "true")
@ -137,30 +108,15 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("telegram", "show_on_reg", "true") app.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false) // These two settings are pretty much the same
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false) url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String()
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"} app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
allDisabled := true app.MustSetValue("invite_emails", "url_base", url2)
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
app.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
@ -191,15 +147,9 @@ func (app *appContext) loadConfig() error {
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("") app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig) app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
if err != nil { if err != nil {
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err) app.err.Printf("Failed to initialize Proxy: %v\n", err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
app.proxyEnabled = false
} else {
app.proxyEnabled = true
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
} }
app.proxyEnabled = true
} }
app.MustSetValue("updates", "enabled", "true") app.MustSetValue("updates", "enabled", "true")
@ -251,98 +201,3 @@ func (app *appContext) loadConfig() error {
return nil return nil
} }
func (app *appContext) PatchConfigBase() {
conf := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
pwrOptions := app.storage.lang.PasswordReset.getOptions()
adminOptions := app.storage.lang.Admin.getOptions()
emailOptions := app.storage.lang.Email.getOptions()
telegramOptions := app.storage.lang.Email.getOptions()
for i, section := range app.configBase.Sections {
if section.Section == "updates" && updater == "" {
section.Meta.Disabled = true
}
for j, setting := range section.Settings {
if section.Section == "ui" {
if setting.Setting == "language-form" {
setting.Options = formOptions
setting.Value = "en-us"
} else if setting.Setting == "language-admin" {
setting.Options = adminOptions
setting.Value = "en-us"
}
} else if section.Section == "password_resets" {
if setting.Setting == "language" {
setting.Options = pwrOptions
setting.Value = "en-us"
}
} else if section.Section == "email" {
if setting.Setting == "language" {
setting.Options = emailOptions
setting.Value = "en-us"
}
} else if section.Section == "telegram" {
if setting.Setting == "language" {
setting.Options = telegramOptions
setting.Value = "en-us"
}
} else if section.Section == "smtp" {
if setting.Setting == "ssl_cert" && PLATFORM == "windows" {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
} else if section.Section == "matrix" {
if setting.Setting == "encryption" && !MatrixE2EE() {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
}
val := app.config.Section(section.Section).Key(setting.Setting)
switch setting.Type {
case "list":
setting.Value = val.StringsWithShadows("|")
case "text", "email", "select", "password", "note":
setting.Value = val.MustString("")
case "number":
setting.Value = val.MustInt(0)
case "bool":
setting.Value = val.MustBool(false)
}
section.Settings[j] = setting
}
conf.Sections[i] = section
}
app.patchedConfig = conf
}
func (app *appContext) PatchConfigDiscordRoles() {
if !discordEnabled {
return
}
r, err := app.discord.ListRoles()
if err != nil {
return
}
roles := make([]common.Option, len(r)+1)
roles[0] = common.Option{"", "None"}
for i, role := range r {
roles[i+1] = role
}
for i, section := range app.patchedConfig.Sections {
if section.Section != "discord" {
continue
}
for j, setting := range section.Settings {
if setting.Setting != "apply_role" {
continue
}
setting.Options = roles
section.Settings[j] = setting
}
app.patchedConfig.Sections[i] = section
}
}

4
config/README.md Normal file
View File

@ -0,0 +1,4 @@
### fixconfig
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so `enumerate/enumerate_config.py` opens the json file, and for each section, adds an "order" array which tells the web page in which order to display settings.
Specify the input and output files with `-i` and `-o` respectively.

View File

@ -1 +0,0 @@
The two python scripts here, `config-json-to-new-yaml.py` and `gen-rough-schema.py` were used to convert the old format, which was stored in a JSON file, to the new format in YAML. The latter script is used to get the possible values for settings and sections, so they could be properly defined in common/config.go.

1740
config/config-base.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +0,0 @@
from ruamel.yaml import YAML
import json
from pathlib import Path
import sys
yaml = YAML()
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
with open(sys.argv[len(sys.argv)-1], 'r') as f:
c = json.load(f)
c.pop("order")
c1 = c.copy()
c1["sections"] = []
for section in c["sections"]:
codeSection = { "section": section }
s = codeSection | c["sections"][section]
s.pop("order")
c1["sections"].append(s)
c2 = c.copy()
c2["sections"] = []
for section in c1["sections"]:
sArray = []
for setting in section["settings"]:
codeSetting = { "setting": setting }
s = codeSetting | section["settings"][setting]
sArray.append(s)
section["settings"] = sArray
c2["sections"].append(section)
yaml.dump(c2, sys.stdout)

View File

@ -1,40 +0,0 @@
import json
import sys
sectionSchema = {}
metaSchema = {}
settingSchema = {}
typeValues = {}
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
with open(sys.argv[len(sys.argv)-1], 'r') as f:
c = json.load(f)
for section in c["sections"]:
for key in c["sections"][section]:
sectionSchema[key] = True
for key in c["sections"][section]["meta"]:
metaSchema[key] = c["sections"][section]["meta"][key]
for setting in c["sections"][section]["settings"]:
for field in c["sections"][section]["settings"][setting]:
settingSchema[field] = c["sections"][section]["settings"][setting][field]
typeValues[c["sections"][section]["settings"][setting]["type"]] = True
print("Section Content:")
for v in sectionSchema:
print(v)
print("---")
print("Meta Schema")
for v in metaSchema:
print(v, "=", type(metaSchema[v]))
print("---")
print("Setting Schema")
for v in settingSchema:
print(v, "=", type(settingSchema[v]))
print("---")
print("Possible Types")
for v in typeValues:
print(v)

View File

@ -18,8 +18,6 @@
--bg-light: #fff; --bg-light: #fff;
--bg-dark: #101010; --bg-dark: #101010;
color-scheme: light;
} }
.light { .light {
@ -28,7 +26,6 @@
.dark { .dark {
--settings-section-button-filter: 80%; --settings-section-button-filter: 80%;
color-scheme: dark !important;
} }
.dark body { .dark body {
@ -65,7 +62,18 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
display: initial; display: initial;
} }
@media screen and (max-width: 1024px) { .page-container {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.page-container {
margin: 2%;
margin-top: 5rem;
}
}
@media screen and (max-width: 1000px) {
:root { :root {
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -98,6 +106,48 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px; padding-bottom: 0px;
} }
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row { .row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -122,7 +172,23 @@ span.sm:not(.heading) {
margin: .25rem; margin: .25rem;
} }
/* Who knows for half of these to be honest */ .flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
@media screen and (max-width: 400px) { @media screen and (max-width: 400px) {
.row { .row {
flex-direction: column; flex-direction: column;
@ -153,6 +219,69 @@ sup.\~critical, .text-critical {
font-size: 1rem; font-size: 1rem;
} }
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: start;
align-items: center;
}
.inv-empty .inv-codearea {
justify-content: start;
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad {
padding: 0px 0px 0px 0px;
}
.elem-pad > * {
margin: var(--spacing-4, 1rem);
}
.icon.clickable {
padding: 0.5rem 0.6rem;
}
.input { .input {
box-sizing: border-box; /* fixes weird length issue with inputs */ box-sizing: border-box; /* fixes weird length issue with inputs */
} }
@ -171,6 +300,10 @@ sup.\~critical, .text-critical {
width: 100%; width: 100%;
} }
.flex-auto {
flex: auto;
}
.center { .center {
justify-content: center; justify-content: center;
} }
@ -179,6 +312,14 @@ sup.\~critical, .text-critical {
align-items: center; align-items: center;
} }
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused { .focused {
display: block; display: block;
} }
@ -275,16 +416,7 @@ table {
color: var(--color-content); color: var(--color-content);
} }
table.table.manual-pad th, table.table.manual-pad td {
padding: 0;
}
table.table-p-0 th, table.table-p-0 td {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
p.top { p.top {
margin-top: 0px; margin-top: 0px;
@ -443,6 +575,7 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer; cursor: pointer;
} }
.g-recaptcha { .g-recaptcha {
overflow: hidden; overflow: hidden;
width: 296px; width: 296px;
@ -454,19 +587,3 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe { .g-recaptcha iframe {
margin: -2px 0px 0px -4px; margin: -2px 0px 0px -4px;
} }
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}
section.section:not(.\~neutral) {
background-color: inherit;
}
@layer components {
.switch input {
@apply mr-1;
}
}

View File

@ -3,10 +3,6 @@
color: rgba(0, 0, 0, 0) !important; color: rgba(0, 0, 0, 0) !important;
} }
.loader.rel {
position: relative;
}
.loader .dot { .loader .dot {
--diameter: 0.5rem; --diameter: 0.5rem;
--radius: calc(var(--diameter) / 2); --radius: calc(var(--diameter) / 2);
@ -19,12 +15,6 @@
left: calc(50% - var(--radius)); left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite; animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
} }
.loader.rel .dot {
position: absolute;
top: 50%;
}
.loader.loader-sm .dot { .loader.loader-sm .dot {
--deviation: 10%; --deviation: 10%;
} }

View File

@ -5,7 +5,6 @@
.tooltip .content { .tooltip .content {
visibility: hidden; visibility: hidden;
opacity: 0;
max-width: 10rem; max-width: 10rem;
min-width: 6rem; min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
@ -14,23 +13,12 @@
border-radius: 6px; border-radius: 6px;
overflow-wrap: break-word; overflow-wrap: break-word;
text-align: center; text-align: center;
transition: opacity 100ms;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: -1rem; top: -1rem;
} }
.tooltip.below .content {
top: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content { .tooltip.right .content {
left: 120%; left: 120%;
} }
@ -43,10 +31,6 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.tooltip:hover .content, .tooltip:hover .content {
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible; visibility: visible;
opacity: 1;
} }

135
daemon.go Normal file
View File

@ -0,0 +1,135 @@
package main
import "time"
// clearEmails removes stored emails for users which no longer exist.
// meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh.
func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses")
emails := app.storage.GetEmails()
for _, email := range emails {
_, status, err := app.jf.UserByID(email.JellyfinID, false)
if status == 200 && err == nil {
continue
}
app.storage.DeleteEmailsKey(email.JellyfinID)
}
}
// clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
_, status, err := app.jf.UserByID(discordUser.JellyfinID, false)
if status == 200 && err == nil {
continue
}
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
}
}
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, status, err := app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
continue
}
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
}
}
// clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, status, err := app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
continue
}
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
}
}
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
}}
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
}
return &daemon
}
func (rt *housekeepingDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
for _, job := range rt.jobs {
job(rt.app)
}
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *housekeepingDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
}

View File

@ -2,12 +2,10 @@ package main
import ( import (
"fmt" "fmt"
"net/http"
"strings" "strings"
"time" "time"
dg "github.com/bwmarrin/discordgo" dg "github.com/bwmarrin/discordgo"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -18,7 +16,7 @@ type DiscordDaemon struct {
username string username string
tokens map[string]VerifToken // Map of pins to tokens. tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users. verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
Channel, InviteChannel struct{ ID, Name string } channelID, channelName, inviteChannelID, inviteChannelName string
guildID string guildID string
serverChannelName, serverName string serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
@ -26,7 +24,6 @@ type DiscordDaemon struct {
app *appContext app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string commandIDs []string
commandDescriptions []*dg.ApplicationCommand
} }
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@ -53,7 +50,6 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
dd.commandHandlers["lang"] = dd.cmdLang dd.commandHandlers["lang"] = dd.cmdLang
dd.commandHandlers["pin"] = dd.cmdPIN dd.commandHandlers["pin"] = dd.cmdPIN
dd.commandHandlers["inv"] = dd.cmdInvite
for _, user := range app.storage.GetDiscord() { for _, user := range app.storage.GetDiscord() {
dd.users[user.ID] = user dd.users[user.ID] = user
} }
@ -61,11 +57,6 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
return dd, nil return dd, nil
} }
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (d *DiscordDaemon) SetTransport(t *http.Transport) {
d.bot.Client.Transport = t
}
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". // NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (d *DiscordDaemon) NewAuthToken() string { func (d *DiscordDaemon) NewAuthToken() string {
pin := genAuthToken() pin := genAuthToken()
@ -99,11 +90,13 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
} }
func (d *DiscordDaemon) run() { func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.messageHandler)
d.bot.AddHandler(d.commandHandler) d.bot.AddHandler(d.commandHandler)
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
if err := d.bot.Open(); err != nil { if err := d.bot.Open(); err != nil {
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err) d.app.err.Printf("Discord: Failed to start daemon: %v", err)
return return
} }
// Wait for everything to populate, it's slow sometimes. // Wait for everything to populate, it's slow sometimes.
@ -121,20 +114,19 @@ func (d *DiscordDaemon) run() {
d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID
guild, err := d.bot.Guild(d.guildID) guild, err := d.bot.Guild(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordGuild, err) d.app.err.Printf("Discord: Failed to get guild: %v", err)
} }
d.serverChannelName = guild.Name d.serverChannelName = guild.Name
d.serverName = guild.Name d.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" { if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.Channel.Name = channel d.channelName = channel
d.serverChannelName += "/" + channel d.serverChannelName += "/" + channel
} }
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) { if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" { if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
d.InviteChannel.Name = invChannel d.inviteChannelName = invChannel
} }
} }
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands() defer d.deregisterCommands()
defer d.bot.Close() defer d.bot.Close()
@ -150,7 +142,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
var r []*dg.Role var r []*dg.Role
r, err = d.bot.GuildRoles(d.guildID) r, err = d.bot.GuildRoles(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordRoles, err) d.app.err.Printf("Discord: Failed to get roles: %v", err)
return return
} }
for _, role := range r { for _, role := range r {
@ -173,62 +165,44 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID) return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID)
} }
// RemoveRole removes the member role to the given user if set.
func (d *DiscordDaemon) RemoveRole(userID string) error {
if d.roleID == "" {
return nil
}
return d.bot.GuildMemberRoleRemove(d.guildID, userID, d.roleID)
}
// SetRoleDisabled removes the role if "disabled", and applies if "!disabled".
func (d *DiscordDaemon) SetRoleDisabled(userID string, disabled bool) (err error) {
if disabled {
err = d.RemoveRole(userID)
} else {
err = d.ApplyRole(userID)
}
return
}
// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon. // NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon.
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) { func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite var inv *dg.Invite
var err error var err error
if d.InviteChannel.Name == "" { if d.inviteChannelName == "" {
d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty) d.app.err.Println("Discord: Cannot create invite without channel specified in settings.")
return return
} }
if d.InviteChannel.ID == "" { if d.inviteChannelID == "" {
channels, err := d.bot.GuildChannels(d.guildID) channels, err := d.bot.GuildChannels(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordChannels, err) d.app.err.Printf("Discord: Couldn't get channel list: %v", err)
return return
} }
found := false found := false
for _, channel := range channels { for _, channel := range channels {
// channel, err := d.bot.Channel(ch.ID) // channel, err := d.bot.Channel(ch.ID)
// if err != nil { // if err != nil {
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err) // d.app.err.Printf("Discord: Couldn't get channel: %v", err)
// return // return
// } // }
if channel.Name == d.InviteChannel.Name { if channel.Name == d.inviteChannelName {
d.InviteChannel.ID = channel.ID d.inviteChannelID = channel.ID
found = true found = true
break break
} }
} }
if !found { if !found {
d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound) d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName)
return return
} }
} }
// channel, err := d.bot.Channel(d.inviteChannelID) // channel, err := d.bot.Channel(d.inviteChannelID)
// if err != nil { // if err != nil {
// d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err) // d.app.err.Printf("Discord: Couldn't get invite channel: %v", err)
// return // return
// } // }
inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{ inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1], // Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
// Channel: channel, // Channel: channel,
// Inviter: d.bot.State.User, // Inviter: d.bot.State.User,
@ -237,15 +211,16 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
Temporary: false, Temporary: false,
}) })
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGenerateDiscordInvite, err) d.app.err.Printf("Discord: Failed to create invite: %v", err)
return return
} }
inviteURL = "https://discord.gg/" + inv.Code inviteURL = "https://discord.gg/" + inv.Code
guild, err := d.bot.Guild(d.guildID) guild, err := d.bot.Guild(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordGuild, err) d.app.err.Printf("Discord: Failed to get guild: %v", err)
return return
} }
// FIXME: Fix CSS, and handle no icon
iconURL = guild.IconURL("256") iconURL = guild.IconURL("256")
return return
} }
@ -278,7 +253,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
1000, 1000,
) )
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err) d.app.err.Printf("Discord: Failed to get members: %v", err)
return nil return nil
} }
hasDiscriminator := strings.Contains(username, "#") hasDiscriminator := strings.Contains(username, "#")
@ -308,7 +283,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) { func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
u, err := d.bot.User(ID) u, err := d.bot.User(ID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetUser, ID, lm.Discord, err) d.app.err.Printf("Discord: Failed to get user: %v", err)
return return
} }
user.ID = ID user.ID = ID
@ -317,7 +292,7 @@ func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
user.Discriminator = u.Discriminator user.Discriminator = u.Discriminator
channel, err := d.bot.UserChannelCreate(ID) channel, err := d.bot.UserChannelCreate(ID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, ID, err) d.app.err.Printf("Discord: Failed to create DM channel: %v", err)
return return
} }
user.ChannelID = channel.ID user.ChannelID = channel.ID
@ -333,7 +308,7 @@ func (d *DiscordDaemon) Shutdown() {
} }
func (d *DiscordDaemon) registerCommands() { func (d *DiscordDaemon) registerCommands() {
d.commandDescriptions = []*dg.ApplicationCommand{ commands := []*dg.ApplicationCommand{
{ {
Name: d.app.config.Section("discord").Key("start_command").MustString("start"), Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
Description: "Start the Discord linking process. The bot will send further instructions.", Description: "Start the Discord linking process. The bot will send further instructions.",
@ -363,78 +338,31 @@ func (d *DiscordDaemon) registerCommands() {
}, },
}, },
}, },
{
Name: "inv",
Description: "Send an invite to a discord user (admin only).",
Options: []*dg.ApplicationCommandOption{
{
Type: dg.ApplicationCommandOptionUser,
Name: "user",
Description: "User to Invite.",
Required: true,
},
{
Type: dg.ApplicationCommandOptionInteger,
Name: "expiry",
Description: "Time in minutes before expiration.",
Required: false,
},
/* Label should be automatically set to something like "Discord invite for @username"
{
Type: dg.ApplicationCommandOptionString,
Name: "label",
Description: "Label given to this invite (shown on the Admin page)",
Required: false,
}, */
{
Type: dg.ApplicationCommandOptionString,
Name: "user_label",
Description: "Label given to users created with this invite.",
Required: false,
},
{
Type: dg.ApplicationCommandOptionString,
Name: "profile",
Description: "Profile to apply to the created user.",
Required: false,
},
},
},
} }
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram)) commands[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
i := 0 i := 0
for code := range d.app.storage.lang.Telegram { for code := range d.app.storage.lang.Telegram {
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Lang, d.app.storage.lang.Telegram[code].Meta.Name+":"+code) d.app.debug.Printf("Registering choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code)
d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{ commands[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: d.app.storage.lang.Telegram[code].Meta.Name, Name: d.app.storage.lang.Telegram[code].Meta.Name,
Value: code, Value: code,
} }
i++ i++
} }
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
// d.deregisterCommands() // d.deregisterCommands()
d.commandIDs = make([]string, len(d.commandDescriptions)) d.commandIDs = make([]string, len(commands))
// cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands) // cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands)
// if err != nil { // if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err) // d.app.err.Printf("Discord: Cannot create commands: %v", err)
// } // }
for i, cmd := range d.commandDescriptions { for i, cmd := range commands {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd) command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err) d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
} else { } else {
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name) d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name)
d.commandIDs[i] = command.ID d.commandIDs[i] = command.ID
} }
} }
@ -443,52 +371,31 @@ func (d *DiscordDaemon) registerCommands() {
func (d *DiscordDaemon) deregisterCommands() { func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID) existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordCommands, err) d.app.err.Printf("Discord: Failed to get commands: %v", err)
return return
} }
for _, cmd := range existingCommands { for _, cmd := range existingCommands {
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil { if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, "", cmd.ID); err != nil {
d.app.err.Printf(lm.FailedDeregDiscordCommand, cmd.Name, err) d.app.err.Printf("Failed to deregister command: %v", err)
} }
} }
} }
// UpdateCommands updates commands which have defined lists of options, to be used when changes occur.
func (d *DiscordDaemon) UpdateCommands() {
// Reload Profile List
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordChoices, lm.Profile, err)
} else {
d.commandIDs[3] = cmd.ID
}
}
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) { func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok { if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.Channel.Name != "" { if i.GuildID != "" && d.channelName != "" {
if d.Channel.ID == "" { if d.channelID == "" {
channel, err := s.Channel(i.ChannelID) channel, err := s.Channel(i.ChannelID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err) d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.app.err.Println(lm.MonitorAllDiscordChannels) d.channelName = ""
d.Channel.Name = ""
} }
if channel.Name == d.Channel.Name { if channel.Name == d.channelName {
d.Channel.ID = channel.ID d.channelID = channel.ID
} }
} }
if d.Channel.ID != i.ChannelID { if d.channelID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord) d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
return return
} }
} }
@ -510,7 +417,7 @@ func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID) channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
return return
} }
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username) user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
@ -527,7 +434,7 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Failed to send reply: %v", err)
return return
} }
} }
@ -545,7 +452,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
} }
delete(d.tokens, pin) delete(d.tokens, pin)
return return
@ -559,7 +466,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
} }
dcUser := d.users[i.Interaction.Member.User.ID] dcUser := d.users[i.Interaction.Member.User.ID]
dcUser.JellyfinID = user.JellyfinID dcUser.JellyfinID = user.JellyfinID
@ -590,124 +497,144 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Failed to send reply: %v", err)
return return
} }
} }
} }
func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID) if m.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
channel, err := s.Channel(m.ChannelID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err) d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
}
}
if d.channelID != m.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
return return
} }
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username) }
d.users[i.Interaction.Member.User.ID] = requester if m.Author.ID == s.State.User.ID {
recipient := i.ApplicationCommandData().Options[0].UserValue(s) return
// d.app.debug.Println(invuser) }
//label := i.ApplicationCommandData().Options[2].StringValue() sects := strings.Split(m.Content, " ")
//profile := i.ApplicationCommandData().Options[3].StringValue() if len(sects) == 0 {
//mins, err := strconv.Atoi(i.ApplicationCommandData().Options[1].StringValue()) return
//if mins > 0 { }
// expmin = mins lang := d.app.storage.lang.chosenTelegramLang
//} if user, ok := d.users[m.Author.ID]; ok {
// Check whether requestor is linked to the admin account if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok {
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID) lang = user.Lang
if !(ok && requesterEmail.Admin) { }
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID)) }
// FIXME: add response message switch msg := sects[0]; msg {
case "!" + d.app.config.Section("discord").Key("start_command").MustString("start"):
d.msgStart(s, m, lang)
case "!lang":
d.msgLang(s, m, sects, lang)
default:
d.msgPIN(s, m, sects, lang)
}
}
func (d *DiscordDaemon) msgStart(s *dg.Session, m *dg.MessageCreate, lang string) {
channel, err := s.UserChannelCreate(m.Author.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err)
return
}
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
d.users[m.Author.ID] = user
_, err = d.bot.ChannelMessageSendReply(m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("discordDMs"), m.Reference())
if err != nil {
d.app.err.Printf("Discord: Failed to send reply to \"%s\": %v", m.Author.Username, err)
return return
} }
var expiryMinutes int64 = 30 content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
userLabel := "" content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"})
profileName := "" _, err = s.ChannelMessageSend(channel.ID, content)
for i, opt := range i.ApplicationCommandData().Options {
if i == 0 {
continue
}
switch opt.Name {
case "expiry":
expiryMinutes = opt.IntValue()
case "user_label":
userLabel = opt.StringValue()
case "profile":
profileName = opt.StringValue()
}
}
currentTime := time.Now()
validTill := currentTime.Add(time.Minute * time.Duration(expiryMinutes))
invite := Invite{
Code: GenerateInviteCode(),
Created: currentTime,
RemainingUses: 1,
UserExpiry: false,
ValidTill: validTill,
UserLabel: userLabel,
Profile: "Default",
Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
}
if profileName != "" {
if _, ok := d.app.storage.GetProfileKey(profileName); ok {
invite.Profile = profileName
}
}
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
d.app.err.Println(invite.SendTo) return
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ }
Type: dg.InteractionResponseChannelMessageWithSource, }
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"), func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
Flags: 64, // Ephemeral if len(sects) == 1 {
}, list := "!lang <lang>\n"
}) for code := range d.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name)
}
_, err := s.ChannelMessageSendReply(
m.ChannelID,
list,
m.Reference(),
)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
return
}
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
var user DiscordUser
for _, u := range d.app.storage.GetDiscord() {
if u.ID == m.Author.ID {
u.Lang = sects[1]
d.app.storage.SetDiscordKey(u.JellyfinID, u)
user = u
break
}
}
d.users[m.Author.ID] = user
}
}
func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if _, ok := d.users[m.Author.ID]; ok {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Failed to get channel: %v", err)
return
}
if channel.Type != dg.ChannelTypeDM {
d.app.debug.Println("Discord: Ignoring message as not a DM")
return
} }
} else { } else {
var err error d.app.debug.Println("Discord: Ignoring message as user was not found")
err = d.app.discord.SendDM(msg, recipient.ID) return
}
user, ok := d.tokens[sects[0]]
if !ok || time.Now().After(user.Expiry) {
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
)
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
d.app.err.Println(invite.SendTo) }
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ delete(d.tokens, sects[0])
Type: dg.InteractionResponseChannelMessageWithSource, return
Data: &dg.InteractionResponseData{ }
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"), _, err := s.ChannelMessageSend(
Flags: 64, // Ephemeral m.ChannelID,
}, d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
}) )
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
} }
} else { dcUser := d.users[m.Author.ID]
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient)) dcUser.JellyfinID = user.JellyfinID
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ d.verifiedTokens[sects[0]] = dcUser
Type: dg.InteractionResponseChannelMessageWithSource, delete(d.tokens, sects[0])
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
}
}
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
} }
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
@ -763,10 +690,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
} }
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself. // UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) { func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
u, ok := d.verifiedTokens[pin] user, ok = d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin) // delete(d.verifiedTokens, pin)
return &u, ok return
} }
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself. // AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
@ -786,44 +713,7 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// Exists returns whether or not the given user exists. // DeleteVerifiedUser removes the token with the given PIN.
func (d *DiscordDaemon) Exists(user ContactMethodUser) bool { func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
return d.UserExists(user.MethodID().(string)) delete(d.verifiedTokens, pin)
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
delete(d.verifiedTokens, PIN)
}
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
func (d *DiscordDaemon) Name() string { return lm.Discord }
func (d *DiscordDaemon) Required() bool {
return d.app.config.Section("discord").Key("required").MustBool(false)
}
func (d *DiscordDaemon) UniqueRequired() bool {
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
}
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
err := d.ApplyRole(u.MethodID().(string))
if err != nil {
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
}
return err
}
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
func (d *DiscordUser) MethodID() any { return d.ID }
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
func (d *DiscordUser) AllowContact() bool { return d.Contact }
func (d *DiscordUser) Store(st *Storage) {
st.SetDiscordKey(d.Jellyfin(), *d)
} }

View File

@ -1,6 +1,6 @@
module github.com/hrfee/jfa-go/easyproxy module github.com/hrfee/jfa-go/easyproxy
go 1.18 go 1.20
require golang.org/x/net v0.15.0 require golang.org/x/net v0.15.0

134
email.go
View File

@ -10,7 +10,6 @@ import (
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
"net/http"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@ -21,7 +20,6 @@ import (
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/mailgun/mailgun-go/v4" "github.com/mailgun/mailgun-go/v4"
@ -94,13 +92,12 @@ func NewEmailer(app *appContext) *Emailer {
if app.proxyEnabled { if app.proxyEnabled {
proxyConf = &app.proxyConfig proxyConf = &app.proxyConfig
} }
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4)) err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), proxyConf)
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil { if err != nil {
app.err.Printf(lm.FailedInitSMTP, err) app.err.Printf("Error while initiating SMTP mailer: %v", err)
} }
} else if method == "mailgun" { } else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport) emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
} else if method == "dummy" { } else if method == "dummy" {
emailer.sender = &DummyClient{} emailer.sender = &DummyClient{}
} }
@ -121,7 +118,7 @@ type SMTP struct {
} }
// NewSMTP returns an SMTP emailClient. // NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) { func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{} sender := &SMTP{}
sender.Client = sMail.NewSMTPClient() sender.Client = sMail.NewSMTPClient()
if sslTLS { if sslTLS {
@ -130,7 +127,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
sender.Client.Encryption = sMail.EncryptionSTARTTLS sender.Client.Encryption = sMail.EncryptionSTARTTLS
} }
if username != "" || password != "" { if username != "" || password != "" {
sender.Client.Authentication = authType sender.Client.Authentication = sMail.AuthLogin
sender.Client.Username = username sender.Client.Username = username
sender.Client.Password = password sender.Client.Password = password
} }
@ -202,14 +199,10 @@ type Mailgun struct {
} }
// NewMailgun returns a Mailgun emailClient. // NewMailgun returns a Mailgun emailClient.
func (emailer *Emailer) NewMailgun(url, key string, transport *http.Transport) { func (emailer *Emailer) NewMailgun(url, key string) {
sender := &Mailgun{ sender := &Mailgun{
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key), client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
} }
if transport != nil {
cli := sender.client.Client()
cli.Transport = transport
}
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages') // Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages')
if strings.Contains(url, "messages") { if strings.Contains(url, "messages") {
url = url[0:strings.LastIndex(url, "/")] url = url[0:strings.LastIndex(url, "/")]
@ -325,11 +318,17 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
} }
} else { } else {
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := app.ExternalURI inviteLink := app.config.Section("invite_emails").Key("url_base").String()
if code == "" { // Personal email change if code == "" { // Personal email change
if strings.HasSuffix(inviteLink, "/invite") {
inviteLink = strings.TrimSuffix(inviteLink, "/invite")
}
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key)) inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation } else { // Invite email confirmation
inviteLink = fmt.Sprintf("%s/invite/%s?key=%s", inviteLink, code, url.PathEscape(key)) if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite"
}
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, url.PathEscape(key))
} }
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink template["confirmationURL"] = inviteLink
@ -393,7 +392,11 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := fmt.Sprintf("%s/invite/%s", app.ExternalURI, code) inviteLink := app.config.Section("invite_emails").Key("url_base").String()
if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite"
}
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
template := map[string]interface{}{ template := map[string]interface{}{
"hello": emailer.lang.InviteEmail.get("hello"), "hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"), "youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
@ -576,7 +579,7 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
// Only used in html email. // Only used in html email.
template["pin_code"] = pwr.Pin template["pin_code"] = pwr.Pin
} else { } else {
app.info.Println(lm.FailedGeneratePWRLink, err) app.info.Println("Couldn't generate PWR link: %v", err)
template["pin"] = pwr.Pin template["pin"] = pwr.Pin
} }
} else { } else {
@ -737,72 +740,6 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
return email, nil return email, nil
} }
func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"newExpiry": "",
"message": "",
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
empty := []string{"reason", "newExpiry"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
exp := app.formatDatetime(expiry)
if !expiry.IsZero() {
if custom {
template["newExpiry"] = exp
} else if !expiry.IsZero() {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": exp,
})
}
}
}
return template
}
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
}
var err error
var template map[string]interface{}
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
if message.Enabled {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
} else {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
}
if noSub {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": "{newExpiry}",
})
}
if message.Enabled {
content := templateEmail(
message.Content,
message.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} { func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{ template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"), "welcome": emailer.lang.WelcomeEmail.get("welcome"),
@ -961,38 +898,30 @@ func (app *appContext) getAddressOrName(jfID string) string {
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username. // ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames. // returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) { func (app *appContext) ReverseUserSearch(address string) (user mediabrowser.User, ok bool) {
ok = false ok = false
var err error = nil user, status, err := app.jf.UserByName(address, false)
if matchUsername { if status == 200 && err == nil {
user, err = app.jf.UserByName(address, false)
if err == nil {
ok = true ok = true
return return
} }
}
if matchEmail {
emailAddresses := []EmailAddress{} emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address)) err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 { if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses { for _, emailUser := range emailAddresses {
user, err = app.jf.UserByID(emailUser.JellyfinID, false) user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if err == nil { if status == 200 && err == nil {
ok = true ok = true
return return
} }
} }
} }
}
// Dont know how we'd use badgerhold when we need to render each username, // Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db. // Apart from storing the rendered name in the db.
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() { for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) { if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, err = app.jf.UserByID(dcUser.JellyfinID, false) user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if err == nil { if status == 200 && err == nil {
ok = true ok = true
return return
} }
@ -1003,8 +932,8 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername)) err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 { if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers { for _, telegramUser := range telegramUsers {
user, err = app.jf.UserByID(telegramUser.JellyfinID, false) user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if err == nil { if status == 200 && err == nil {
ok = true ok = true
return return
} }
@ -1014,14 +943,13 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address)) err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 { if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers { for _, matrixUser := range matrixUsers {
user, err = app.jf.UserByID(matrixUser.JellyfinID, false) user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if err == nil { if status == 200 && err == nil {
ok = true ok = true
return return
} }
} }
} }
}
return return
} }

View File

@ -1,4 +1,3 @@
//go:build external
// +build external // +build external
package main package main
@ -13,8 +12,6 @@ import (
const binaryType = "external" const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
var localFS dirFS var localFS dirFS
var langFS dirFS var langFS dirFS

View File

@ -1,69 +0,0 @@
package main
import (
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
name string
}
func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) {
d.jobs = append(d.jobs, jobs...)
}
// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext.
func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon {
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
name: "Generic Daemon",
}
d.jobs = jobs
return &d
}
func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() {
d.app.info.Printf(lm.StartDaemon, d.name)
for {
select {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-time.After(d.period):
break
}
started := time.Now()
for _, job := range d.jobs {
job(d.app)
}
finished := time.Now()
duration := finished.Sub(started)
d.period = d.Interval - duration
}
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
<-d.ShutdownChannel
close(d.ShutdownChannel)
}

142
go.mod
View File

@ -1,8 +1,6 @@
module github.com/hrfee/jfa-go module github.com/hrfee/jfa-go
go 1.22 go 1.20
toolchain go1.22.4
replace github.com/hrfee/jfa-go/docs => ./docs replace github.com/hrfee/jfa-go/docs => ./docs
@ -12,134 +10,124 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi
replace github.com/hrfee/jfa-go/logger => ./logger replace github.com/hrfee/jfa-go/logger => ./logger
replace github.com/hrfee/jfa-go/logmessages => ./logmessages
replace github.com/hrfee/jfa-go/linecache => ./linecache replace github.com/hrfee/jfa-go/linecache => ./linecache
replace github.com/hrfee/jfa-go/api => ./api replace github.com/hrfee/jfa-go/api => ./api
replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
require ( require (
github.com/bwmarrin/discordgo v0.28.1 github.com/bwmarrin/discordgo v0.27.1
github.com/dgraph-io/badger/v3 v3.2103.5
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/fatih/color v1.17.0 github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.6.0
github.com/getlantern/systray v1.2.2 github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.5.0 github.com/gin-contrib/pprof v1.4.0
github.com/gin-contrib/static v1.1.2 github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.9.1
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
github.com/hrfee/jfa-go/common v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/jfa-go/common v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/docs v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/easyproxy v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/linecache v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/logger v0.0.0-20240824141650-fcdd4e451882 github.com/hrfee/mediabrowser v0.3.12
github.com/hrfee/jfa-go/logmessages v0.0.0-20240824141650-fcdd4e451882 github.com/itchyny/timefmt-go v0.1.5
github.com/hrfee/jfa-go/ombi v0.0.0-20240824141650-fcdd4e451882
github.com/hrfee/mediabrowser v0.3.18
github.com/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7 github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.15.0 github.com/mailgun/mailgun-go/v4 v4.9.1
github.com/mattn/go-sqlite3 v1.14.22
github.com/robert-nix/ansihtml v1.0.1 github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1 github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/gin-swagger v1.6.0
github.com/timshannon/badgerhold/v4 v4.0.3 github.com/timshannon/badgerhold/v4 v4.0.2
github.com/writeas/go-strip-markdown v2.0.1+incompatible github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.15.3
maunium.net/go/mautrix v0.20.0
) )
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.12.1 // indirect github.com/bytedance/sonic v1.9.2 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/badger/v3 v3.2103.5 // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/errors v1.0.3 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-logr/logr v1.2.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/go-playground/validator/v10 v10.14.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect github.com/go-stack/stack v1.8.1 // indirect
github.com/go-test/deep v1.1.0 // indirect github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/glog v1.2.2 // indirect github.com/golang/glog v1.1.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/compress v1.16.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.33.0 // indirect github.com/rs/zerolog v1.29.1 // indirect
github.com/swaggo/swag v1.16.3 // indirect github.com/swaggo/swag v1.16.1 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.17.3 // indirect github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
go.mau.fi/util v0.7.0 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.16.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/arch v0.9.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.26.0 // indirect golang.org/x/crypto v0.13.0 // indirect
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/image v0.19.0 // indirect golang.org/x/image v0.8.0 // indirect
golang.org/x/net v0.28.0 // indirect golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.24.0 // indirect golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.17.0 // indirect golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.24.0 // indirect golang.org/x/tools v0.10.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
) )

360
go.sum
View File

@ -1,7 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
@ -11,27 +10,23 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 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/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@ -39,14 +34,14 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.1/go.mod h1:dULbq6ehJ5K0cGW/1TQ9iSfUk0gbSiToDWmWmTsJ53E=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs=
github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
@ -67,20 +62,20 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqL
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0= github.com/getlantern/errors v1.0.3 h1:Ne4Ycj7NI1BtSyAfVeAT/DNoxz7/S2BUc3L2Ht1YSHE=
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY= github.com/getlantern/errors v1.0.3/go.mod h1:m8C7H1qmouvsGpwQqk/6NUpIVMpfzUPn608aBZDYV04=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
@ -92,63 +87,71 @@ github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2y
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c h1:qcPAzA1ZDnwx618jAgQmxo6UvJkw2SkM1L4ofncmEhI=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y= github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c/go.mod h1:g2ueCncOwWenlAr56Fh90FwsACkelqqtFUDLAHg1mng=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w= github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU= github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4= github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw= github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
@ -156,8 +159,9 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaEL
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@ -166,9 +170,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw=
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/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-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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -177,6 +181,7 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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.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.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.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 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.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.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
@ -185,18 +190,19 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw= github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 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.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.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -206,49 +212,53 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.18 h1:6UDyae0srEVjiMG+utPQfJJp4UId6/T3WN9EDCQKRTk= github.com/hrfee/mediabrowser v0.3.12 h1:fqDxt1be3e+ZNjAtlKc8MTqg7peo6fuGCrk2wOXo20k=
github.com/hrfee/mediabrowser v0.3.18/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.12/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
@ -256,28 +266,27 @@ github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI= github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg= github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
github.com/mailgun/mailgun-go/v4 v4.14.0 h1:N9GMNs0XUq5nn3v2TlZHCknF9khSh88MkA1hdRm1o7I= github.com/mailgun/mailgun-go/v4 v4.9.1 h1:D/jhJXYod4RqRsNOOSrjrtAcMEnz8mPYJmeA5cueHKY=
github.com/mailgun/mailgun-go/v4 v4.14.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8= github.com/mailgun/mailgun-go/v4 v4.9.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/mailgun/mailgun-go/v4 v4.15.0 h1:3NQU0r2XItJbyIZ21iBI9ps0+vPIMoGyI2XK7ZTN/DQ=
github.com/mailgun/mailgun-go/v4 v4.15.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -287,15 +296,14 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -304,11 +312,12 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc= github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE= github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
@ -329,7 +338,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -338,9 +346,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
@ -349,13 +357,13 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y= github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@ -363,24 +371,31 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timshannon/badgerhold/v4 v4.0.3 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8= github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e h1:zWSVsQaifg0cVH9VvR+cMguV7exK6U+SoW8YD1cZpR4=
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8= github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e/go.mod h1:/Seq5xGNo8jLhSbDX3jdbeZrp4yFIpQ6/7n4TjziEWs=
github.com/timshannon/badgerhold/v4 v4.0.2 h1:83OLY/NFnEaMnHEPd84bYtkLipVkjTsMbzQRYbk47g4=
github.com/timshannon/badgerhold/v4 v4.0.2/go.mod h1:rh6RyXLQFsvrvcKondPQQFZnNovpRzu+gS0FlLxYuHY=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI= github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
@ -388,56 +403,50 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/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= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.6.0 h1:W6SyB3Bm/GjenQ5iq8Z8WWdN85Gy2xS6L0wmnR7SVjg=
go.mau.fi/util v0.6.0/go.mod h1:ljYdq3sPfpICc3zMU+/mHV/sa4z0nKxc67hSBwnrk8U=
go.mau.fi/util v0.7.0 h1:l31z+ivrSQw+cv/9eFebEqtQW2zhxivGypn+JT0h/ws=
go.mau.fi/util v0.7.0/go.mod h1:bWYreIoTULL/UiRbZdfddPh7uWDFW5yX4YCv5FB0eE0=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo= go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@ -447,8 +456,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/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.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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -466,12 +474,14 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -483,8 +493,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -499,33 +507,39 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20210510120138-977fb7262007/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-20210615035016-665e8c7367d1/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-20210927094055-39ccf1dd6fa6/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-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.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.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-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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 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.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-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-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -540,8 +554,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -569,15 +583,18 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 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/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
@ -585,14 +602,17 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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-20190102054323-c2f93a96b099/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.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/mautrix v0.19.0 h1:67eSJWam93mw44Q0/1SiOG7zQzXMUknUv5UaWkrODDU= maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/mautrix v0.19.0/go.mod h1:UE+mSQ4sDUuJMbjN0aB9EjQSGgXd48AzMvZ6+QJV1k8= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.20.0 h1:bzQnVQR+LvQxV1YlAr7BSWCS8AWa0Ov0lyPhbbChM0o= maunium.net/go/mautrix v0.15.3 h1:C9BHSUM0gYbuZmAtopuLjIcH5XHLb/ZjTEz7nN+0jN0=
maunium.net/go/mautrix v0.20.0/go.mod h1:V725r8w7oddsS7CxnmTAp634A4nwJCFY7J3jiTMUz2c= maunium.net/go/mautrix v0.15.3/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -1,163 +0,0 @@
package main
import (
"time"
"github.com/dgraph-io/badger/v3"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
// clearEmails removes stored emails for users which no longer exist.
// meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh.
func (app *appContext) clearEmails() {
app.debug.Println(lm.HousekeepingEmail)
emails := app.storage.GetEmails()
for _, email := range emails {
_, err := app.jf.UserByID(email.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteEmailsKey(email.JellyfinID)
default:
continue
}
}
}
// clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() {
app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
user, err := app.jf.UserByID(discordUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
// Remove role in case their account was deleted oustide of jfa-go
app.discord.RemoveRole(discordUser.MethodID().(string))
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
if user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue
}
}
}
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println(lm.HousekeepingMatrix)
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, err := app.jf.UserByID(matrixUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
default:
continue
}
}
}
// clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() {
app.debug.Println(lm.HousekeepingTelegram)
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, err := app.jf.UserByID(telegramUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
default:
continue
}
}
}
func (app *appContext) clearPWRCaptchas() {
app.debug.Println(lm.HousekeepingCaptcha)
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
captchas[k] = capt
}
}
app.pwrCaptchas = captchas
}
func (app *appContext) clearActivities() {
app.debug.Println(lm.HousekeepingActivity)
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
err := error(nil)
errorSource := 0
if maxAgeDays != 0 {
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
}
if err == nil && keepCount != 0 {
// app.debug.Printf("Keeping %d records", keepCount)
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
if err != nil {
errorSource = 1
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{}
if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
} else {
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
}
for _, record := range list {
app.storage.DeleteActivityKey(record.ID)
}
}
}
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println(lm.HousekeepingInvites)
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
)
d.Name("Housekeeping")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := discordEnabled && (app.config.Section("discord").Key("require_unique").MustBool(false) || app.config.Section("discord").Key("disable_enable_role").MustBool(false))
clearTelegram := telegramEnabled && (app.config.Section("telegram").Key("require_unique").MustBool(false))
clearMatrix := matrixEnabled && (app.config.Section("matrix").Key("require_unique").MustBool(false))
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
d.appendJobs(func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
d.appendJobs(func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
d.appendJobs(func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
d.appendJobs(func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
}
return d
}

View File

@ -6,7 +6,7 @@
<title>404 - jfa-go</title> <title>404 - jfa-go</title>
</head> </head>
<body class="section"> <body class="section">
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card"> <div class="card">
<h1 class="heading">Page not found.</h1> <h1 class="heading">Page not found.</h1>
<p class="content"> <p class="content">

View File

@ -10,7 +10,6 @@
window.discordEnabled = {{ .discordEnabled }}; window.discordEnabled = {{ .discordEnabled }};
window.matrixEnabled = {{ .matrixEnabled }}; window.matrixEnabled = {{ .matrixEnabled }};
window.ombiEnabled = {{ .ombiEnabled }}; window.ombiEnabled = {{ .ombiEnabled }};
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.usernameEnabled = {{ .username }}; window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }}); window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }}; window.linkResetEnabled = {{ .linkResetEnabled }};
@ -27,7 +26,7 @@
<body class="max-w-full overflow-x-hidden section"> <body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }} {{ template "login-modal.html" . }}
<div id="modal-add-user" class="modal"> <div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}"> <input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
@ -44,32 +43,31 @@
</form> </form>
</div> </div>
<div id="modal-about" class="modal"> <div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/2 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner"> <img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span> <span class="heading"><span class="modal-close">&times;</span></span>
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p> <p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p> <p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p> <p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p> <p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<p>{{ .strings.buildTags }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTags }}</span></p> <div class="row col flex">
<div class="flex flex-row flex-wrap gap-2 my-2"> <a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a> <a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a> <a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a> <div class="dropdown mr-2" tabindex="0">
<div class="dropdown" tabindex="0"> <a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i> <i class="ri-hand-heart-line mr-2"></i>
donate donate
<span class="ml-2 chev"></span> <span class="ml-2 chev"></span>
</a> </a>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a> <a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a> <a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
</div> </div>
</div> </div>
</div> </div>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a> <a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
</div> </div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p> <p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre> <pre class="font-mono bg-inherit">{{ .license }}</pre>
@ -82,60 +80,46 @@
</div> </div>
</div> </div>
<div id="modal-modify-user" class="modal"> <div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p> <p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-col gap-4 my-2"> <div class="flex flex-row mb-4">
<div class="flex flex-row gap-2"> <label class="flex-row-group mr-2">
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked> <input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label> </label>
<label class="grow"> <label class="flex-row-group ml-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user"> <input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label> </label>
</div> </div>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-4">
<select id="modify-user-profiles"></select> <select id="modify-user-profiles"></select>
</div> </div>
<div class="select ~neutral @low unfocused"> <div class="select ~neutral @low mb-4 unfocused">
<select id="modify-user-users"></select> <select id="modify-user-users"></select>
</div> </div>
<label class="switch"> <label class="switch mb-4">
<input type="checkbox" id="modify-user-configuration" checked>
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-homescreen" checked> <input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span> <span>{{ .strings.applyHomescreenLayout }}</span>
</label> </label>
<label class="switch">
<input type="checkbox" id="modify-user-ombi" checked>
<span>{{ .strings.applyOmbi }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-jellyseerr" checked>
<span>{{ .strings.applyJellyseerr }}</span>
</label>
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span> <span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label> </label>
</div>
</form> </form>
</div> </div>
{{ if .referralsEnabled }} {{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal"> <div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p> <p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4"> <div class="flex flex-row mb-4">
<label class="grow mr-2"> <label class="flex-row-group mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked> <input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label> </label>
<label class="grow ml-2"> <label class="flex-row-group ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite"> <input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label> </label>
@ -146,11 +130,6 @@
<div class="select ~neutral @low mb-4 unfocused"> <div class="select ~neutral @low mb-4 unfocused">
<select id="enable-referrals-user-invites"></select> <select id="enable-referrals-user-invites"></select>
</div> </div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-user-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span> <span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@ -158,18 +137,13 @@
</form> </form>
</div> </div>
<div id="modal-enable-referrals-profile" class="modal"> <div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p> <p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label> <label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
<div class="select ~neutral @low mb-4 mt-2"> <div class="select ~neutral @low mb-4 mt-2">
<select id="enable-referrals-profile-invites"></select> <select id="enable-referrals-profile-invites"></select>
</div> </div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-profile-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span> <span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@ -178,7 +152,7 @@
</div> </div>
{{ end }} {{ end }}
<div id="modal-delete-user" class="modal"> <div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="content mt-8">
<label class="switch mb-4"> <label class="switch mb-4">
@ -194,18 +168,9 @@
</form> </form>
</div> </div>
<div id="modal-extend-expiry" class="modal"> <div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="content mt-8">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
</div>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
@ -242,7 +207,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<label class="switch mb-4"> <label class="switch mb-4">
<input type="checkbox" id="expiry-extend-enable" checked> <input type="checkbox" id="expiry-extend-enable" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span> <span>{{ .strings.sendDeleteNotificationEmail }}</span>
@ -258,7 +222,7 @@
<div id="modal-announce" class="modal"> <div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href=""> <form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="flex flex-col md:flex-row"> <div class="row">
<div class="col card ~neutral @low"> <div class="col card ~neutral @low">
<div id="announce-details"> <div id="announce-details">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span> <span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
@ -275,7 +239,7 @@
<input type="text" class="input ~neutral @low mb-2 mt-4"> <input type="text" class="input ~neutral @low mb-2 mt-4">
<p class="support">{{ .strings.templateEnterName }}</p> <p class="support">{{ .strings.templateEnterName }}</p>
</label> </label>
<div class="flex flex-row justify-between"> <div class="row flex-expand">
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span> <span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@ -291,10 +255,10 @@
</form> </form>
</div> </div>
<div id="modal-customize" class="modal"> <div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p> <p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class=""> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -312,33 +276,30 @@
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href=""> <form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href="">
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row"> <div class="row">
<div class="col card ~neutral @low flex flex-col gap-2 justify-between"> <div class="col card ~neutral @low">
<div class="flex flex-col gap-2"> <span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside> <div id="editor-variables" class="mt-4"></div>
<label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label>
<div id="editor-variables" class="flex flex-row gap-2 flex-wrap"></div>
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span> <span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
<div id="editor-conditionals"></div> <div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label> <label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea> <textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low mt-4 font-mono"></textarea>
</div> <p class="support mt-4 mb-2">{{ .strings.markdownSupported }}</p>
<div class="flex flex-col gap-2"> <div class="flex-row">
<p class="support">{{ .strings.markdownSupported }}</p> <label class="full-width ml-2">
<label class="w-full">
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span> <span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label> </label>
</div> </div>
</div> </div>
<div class="col card ~neutral @low flex flex-col gap-2"> <div class="col card ~neutral @low">
<span class="subheading supra">{{ .strings.preview }}</span> <span class="subheading supra">{{ .strings.preview }}</span>
<div id="editor-preview"></div> <div class="mt-8" id="editor-preview"></div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div id="modal-restart" class="modal"> <div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p> <p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right"> <div class="float-right">
@ -347,62 +308,21 @@
</div> </div>
</div> </div>
</div> </div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
<ul>
<li>{{ .strings.backupsCopy }}</li>
<li>{{ .strings.backupsFormatNote }}</li>
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
</ul>
</div>
<div class="flex flex-row flex-wrap my-2">
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
<tbody id="backups-list"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal"> <div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span> <span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p> <p class="content">{{ .strings.settingsRefreshPage }}</p>
</div> </div>
</div> </div>
<div id="modal-send-pwr" class="modal"> <div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.sendPWR }}</span> <span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p> <p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span> <span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div> </div>
</div> </div>
<div id="modal-ombi-profile" class="modal"> <div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p> <p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4"> <div class="select ~neutral @low mb-4">
@ -414,21 +334,8 @@
</label> </label>
</form> </form>
</div> </div>
<div id="modal-jellyseerr-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
<select></select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="modal-user-profiles" class="modal"> <div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p> <p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive"> <div class="table-responsive">
@ -440,9 +347,6 @@
{{ if .ombiEnabled }} {{ if .ombiEnabled }}
<th>Ombi</th> <th>Ombi</th>
{{ end }} {{ end }}
{{ if .jellyseerrEnabled }}
<th>Jellyseerr</th>
{{ end }}
{{ if .referralsEnabled }} {{ if .referralsEnabled }}
<th>{{ .strings.referrals }}</th> <th>{{ .strings.referrals }}</th>
{{ end }} {{ end }}
@ -457,7 +361,7 @@
</div> </div>
</div> </div>
<div id="modal-add-profile" class="modal"> <div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.addProfileDescription }}</p> <p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label> <label>
@ -480,7 +384,7 @@
</form> </form>
</div> </div>
<div id="modal-update" class="modal"> <div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content"> <p class="content">
<h2 class="mt-2"> <h2 class="mt-2">
@ -496,7 +400,7 @@
</div> </div>
{{ if .telegramEnabled }} {{ if .telegramEnabled }}
<div id="modal-telegram" class="modal"> <div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span> <span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p> <p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1> <h1 class="ac" id="telegram-pin"></h1>
@ -514,7 +418,7 @@
{{ end }} {{ end }}
{{ if .discordEnabled }} {{ if .discordEnabled }}
<div id="modal-discord" class="modal"> <div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span> <span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p> <p class="content mb-4" id="discord-description"></p>
<div class="row"> <div class="row">
@ -525,7 +429,7 @@
</div> </div>
{{ end }} {{ end }}
<div id="modal-matrix" class="modal"> <div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span> <span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p> <p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
@ -538,9 +442,7 @@
</form> </form>
</div> </div>
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4"> <div class="top-4 left-4 absolute">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown"> <span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -562,73 +464,76 @@
</span> </span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span> <span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div> </div>
<div class="flex flex-row gap-2">
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
{{ if .userPageEnabled }} {{ if .userPageEnabled }}
<div class=""> <div class="top-4 right-4 absolute">
<a class="button ~info" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a> <a class="button ~info" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div> </div>
{{ end }} {{ end }}
</div> <div class="page-container">
</div> <div class="mb-4">
<header> <header class="flex flex-wrap items-center justify-between">
<div class="flex flex-row overflow-x-scroll items-center gap-2"> <div>
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</span> <span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</span> <span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</span> <span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</span>
</div> </div>
</header> </header>
<div id="tab-invites" class="flex flex-col gap-4"> </div>
<div class="card @low dark:~d_neutral flex flex-col gap-2 overflow-visible invites"> <div class="mb-4">
<div>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
</div>
<div id="tab-invites">
<div class="card @low invites dark:~d_neutral mb-4">
<span class="heading">{{ .strings.invites }}</span> <span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div> <div id="invites"></div>
</div> </div>
<div class="card @low dark:~d_neutral flex flex-col gap-2"> <div class="card @low dark:~d_neutral">
<span class="heading">{{ .strings.create }}</span> <span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3" id="create-inv"> <div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 flex-1"> <div class="card ~neutral @low col">
<div class="flex flex-row gap-2"> <div class="row mb-2">
<label class="w-1/2"> <label class="col mr-2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked> <input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label> </label>
<label class="w-1/2"> <label class="col ml-2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry"> <input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label> </label>
</div> </div>
<div id="inv-duration" class="flex flex-col gap-2"> <div id="inv-duration">
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-months"> <select id="create-months">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-days"> <select id="create-days">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label> <label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-hours"> <select id="create-hours">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label> <label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-minutes"> <select id="create-minutes">
<option>0</option> <option>0</option>
</select> </select>
@ -636,46 +541,44 @@
</div> </div>
</div> </div>
</div> </div>
<div id="user-expiry" class="unfocused flex flex-col gap-2"> <div id="user-expiry" class="unfocused">
<div class="flex flex-row gap-2"> <p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<p class="support">{{ .strings.userExpiryDescription }}</p> <div class="mb-2">
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low"> <label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled"> <input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span> <span class="ml-2">{{ .strings.enabled }} </span>
</label> </label>
</div> </div>
</div> <div class="row">
<div class="flex flex-row gap-2"> <div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-months"> <select id="user-months">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-days"> <select id="user-days">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label> <label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-hours"> <select id="user-hours">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label> <label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-minutes"> <select id="user-minutes">
<option>0</option> <option>0</option>
</select> </select>
@ -683,91 +586,77 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-label"> {{ .strings.label }}</label> <label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral @low"> <input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
</div> </div>
<div class="flex flex-col gap-4"> <div class="col">
<div>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label> <label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p> <p class="support">{{ .strings.userLabelDescription }}</p>
</div> <input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="create-user-label" class="input ~neutral @low">
</div> </div>
</div> </div>
<div class="card ~neutral @low flex flex-col justify-between gap-2 flex-1"> <div class="card ~neutral @low col">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label> <label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex flex-row gap-2"> <div class="flex-expand mb-2 mt-4">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1> <input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite"> <label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span> <span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite"> <input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label> </label>
</div> </div>
</div> <p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<div class="flex flex-col gap-4">
<label class="label supra">{{ .strings.profile }}</label> <label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-profile"> <select id="create-profile">
</select> </select>
</div> </div>
</div> <div id="create-send-to-container">
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label> <label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2"> <div class="flex-expand mb-2 mt-4">
{{ if .discordEnabled }} {{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234"> <input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low"> <span id="create-send-to-search" class="button ~neutral @low mr-2">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i> <i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span> </span>
{{ else }} {{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com"> <input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
{{ end }} {{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low"> <label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled"> <input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label> </label>
</div> </div>
</div> </div>
</div>
<div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span> <span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div id="tab-accounts" class="unfocused">
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible"> <div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full"> <div class="flex-expand align-middle">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span> <span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span> <div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
</div> <span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="flex flex-row align-middle w-full"> <div class="dropdown-display">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}"> <div class="card ~neutral @low mt-2" id="accounts-filter-list">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p> <p class="supra pb-2">{{ .strings.filters }}</p>
</div> </div>
</div> </div>
</div> </div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center -ml-8" id="accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div> <div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2"> <div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button> <button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span> <span id="accounts-filter-area"></span>
</div> </div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div> <div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4"> <div class="row -mx-2">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span> <span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0"> <div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span> <span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span> <span class="supra sm">{{ .strings.templates }}</span>
@ -775,29 +664,21 @@
</div> </div>
</div> </div>
</div> </div>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span> <span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }} {{ if .referralsEnabled }}
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span> <span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }} {{ end }}
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0"> <span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span> <div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<div class="dropdown-display"> <span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span> <span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div> </div>
</div> </div>
</div> </div>
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span> <span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span> <span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div> </div>
<div class="card @low accounts-header table-responsive mt-2"> <div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4"> <table class="table text-base leading-4">
@ -827,115 +708,32 @@
</thead> </thead>
<tbody id="accounts-list"></tbody> <tbody id="accounts-list"></tbody>
</table> </table>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> <div id="tab-settings" class="unfocused">
</div> <div class="card @low dark:~d_neutral settings overflow">
<div id="tab-activity" class="flex flex-col gap-4 unfocused"> <div class="flex-expand">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible"> <div class="flex-row">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
</div>
<div class="flex justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
</div>
</div>
</div>
</div>
<div id="tab-settings" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral settings overflow flex flex-col gap-2">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="heading">{{ .strings.settings }}</span> <span class="heading">{{ .strings.settings }}</span>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2"> <label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled"> <input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span> <span class="ml-2">{{ .strings.advancedSettings }} </span>
</label> </label>
</div> </div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full"> <div>
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span> <span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span> <span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span> <span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="flex flex-col md:flex-row gap-3">
<div class="md:card @low dark:~d_neutral flex md:flex flex-col gap-2 flex-1" id="settings-sidebar"> <div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="flex flex-row justify-between"> <aside class="aside sm ~urge dark:~d_info mb-2 @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}"> <span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button> <span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
<aside class="aside sm ~urge dark:~d_info @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<div id="settings-loader" class="flex flex-row flex-wrap gap-2">
<span class="button ~neutral @low justify-center grow" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a>
<span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
</div>
<div class="card ~neutral @low overflow flex-1" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
<span class="mb-2 px-12 text-center">{{ .strings.settingsMaybeUnderAdvanced }}</span>
<button class="button ~neutral @low settings-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div> </div>
<div class="card ~neutral @low col overflow" id="settings-panel"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,7 +6,7 @@
<title>Crash report</title> <title>Crash report</title>
</head> </head>
<body> <body>
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card ~critical sectioned"> <div class="card ~critical sectioned">
<section class="section ~critical"> <section class="section ~critical">
<span class="heading">Crash report for jfa-go</span> <span class="heading">Crash report for jfa-go</span>
@ -18,7 +18,7 @@
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a> <a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section> </section>
<section class="section ~neutral @low"> <section class="section ~neutral @low">
<div class="flex flex-row justify-between"> <div class="flex-expand">
<span class="subheading">Full Log</span> <span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span> <span class="button ~urge ml-4" id="copy-log">Copy</span>
</div> </div>

View File

@ -6,7 +6,7 @@
<title>{{ .strings.successHeader }} - jfa-go</title> <title>{{ .strings.successHeader }} - jfa-go</title>
</head> </head>
<body class="section"> <body class="section">
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card ~neutral @low mb-4"> <div class="card ~neutral @low mb-4">
<span class="heading mb-4">{{ .strings.successHeader }}</span> <span class="heading mb-4">{{ .strings.successHeader }}</span>
<p class="content my-4">{{ .successMessage }}</p> <p class="content my-4">{{ .successMessage }}</p>

View File

@ -31,20 +31,11 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }}; window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}"; window.userPageAddress = "{{ .userPageAddress }}";
{{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }};
{{ else }}
window.customSuccessCard = false;
{{ end }}
</script> </script>
{{ if .passwordReset }} {{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script> <script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }} {{ else }}
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }} {{ if .reCAPTCHA }}
<script> <script>
var reCAPTCHACallback = () => { var reCAPTCHACallback = () => {
@ -58,3 +49,4 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script> <script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ end }}

View File

@ -14,19 +14,12 @@
</head> </head>
<body class="max-w-full overflow-x-hidden section"> <body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal"> <div id="modal-success" class="modal">
{{ if .customSuccessCard }}
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
{{ .customSuccessCardContent }}
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ else }}
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span> <span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p> <p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }} {{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a> <a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div> </div>
{{ end }}
</div> </div>
<div id="modal-confirmation" class="modal"> <div id="modal-confirmation" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
@ -35,7 +28,7 @@
</div> </div>
</div> </div>
{{ template "account-linking.html" . }} {{ template "account-linking.html" . }}
<div class="top-2 left-2 absolute"> <div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -48,7 +41,7 @@
</span> </span>
</div> </div>
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card dark:~d_neutral @low"> <div class="card dark:~d_neutral @low">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2"> <div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading mr-5"> <span class="heading mr-5">

View File

@ -2,7 +2,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users."> <meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="robots" content="noindex">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">

View File

@ -6,7 +6,7 @@
<title>Invalid Code - jfa-go</title> <title>Invalid Code - jfa-go</title>
</head> </head>
<body class="section"> <body class="section">
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card"> <div class="card">
<h1 class="text-3xl font-semibold">Invalid invite code.</h1> <h1 class="text-3xl font-semibold">Invalid invite code.</h1>
<p class="content">The code above was either incorrect, or has expired.</p> <p class="content">The code above was either incorrect, or has expired.</p>

View File

@ -1,25 +1,21 @@
<span class="lg:w-[55%]"></span> <!-- the if statement around the 55% width below messes up tailwind, so we force include it here --!>
<div id="modal-login" class="modal"> <div id="modal-login" class="modal">
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2"> <div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
{{ $hasTwoCards := 0 }}
{{ if index . "LoginMessageEnabled" }} {{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }} {{ if .LoginMessageEnabled }}
{{ $hasTwoCards = 1 }} <div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }} {{ .LoginMessageContent }}
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ if index . "userPageEnabled" }} {{ if index . "userPageEnabled" }}
{{ if and .userPageEnabled .showUserPageLink }} {{ if and .userPageEnabled .showUserPageLink }}
{{ $hasTwoCards = 1 }} <div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span> <span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a> <a class="button ~info h-12 w-100" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
<form class="card mx-2 form-login w-full {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href=""> <form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
<span class="heading">{{ .strings.login }}</span> <span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password"> <input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">

View File

@ -11,7 +11,7 @@
<span id="copy-notification" class="unfocused">{{ .strings.copied }}</span> <span id="copy-notification" class="unfocused">{{ .strings.copied }}</span>
</div> </div>
{{ end }} {{ end }}
<div class="page-container m-2 lg:my-20 lg:mx-64"> <div class="page-container">
<div class="card ~neutral @low mb-4"> <div class="card ~neutral @low mb-4">
<span class="heading mb-4"> <span class="heading mb-4">
{{ if .success }} {{ if .success }}
@ -35,7 +35,7 @@
<aside class="aside ~warning"> <aside class="aside ~warning">
{{ .strings.changeYourPassword }} {{ .strings.changeYourPassword }}
</aside> </aside>
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span> <span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }} {{ end }}
</div> </div>
<i class="content">{{ .contactMessage }}</i> <i class="content">{{ .contactMessage }}</i>

View File

@ -7,9 +7,7 @@
</head> </head>
<body class="max-w-full overflow-x-hidden section"> <body class="max-w-full overflow-x-hidden section">
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4"> <div class="top-4 left-4 absolute">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -21,83 +19,79 @@
</div> </div>
</span> </span>
</div> </div>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span> <div class="page-container" id="page-container">
<div class="card ~neutral @low mb-2">
<div class="row">
<img class="banner header" src="banner.svg" alt="jfa-go" />
</div> </div>
<div class="card sectioned ~neutral @low flex flex-col gap-4 justify-between items-center"> <div class="row col flex center">
<img class="w-[105%] max-w-none" src="banner.svg" alt="jfa-go" />
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span> <span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
<p class="content text-center">{{ .lang.StartPage.pressStart }}</p> </div>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center"> <div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span> <span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span> <span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Language.title }}</span> <span class="heading">{{ .lang.Language.title }}</span>
<p class="content" id="language-description"></p> <p class="content my-2" id="language-description"></p>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Language.defaultAdminLang }}</span> <span class="mt-4">{{ .lang.Language.defaultAdminLang }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="ui-language-admin"> <select id="ui-language-admin">
</select> </select>
</div> </div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Language.defaultFormLang }}</span> <span class="mt-4">{{ .lang.Language.defaultFormLang }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="ui-language-form"> <select id="ui-language-form">
</select> </select>
</div> </div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Language.defaultEmailLang }}</span> <span class="mt-4">{{ .lang.Language.defaultEmailLang }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="email-language"> <select id="email-language">
</select> </select>
</div> </div>
</label> </label>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.General.title }}</span> <span class="heading">{{ .lang.General.title }}</span>
<div class="flex flex-row gap-2 justify-between"> <div class="row">
<div class="flex flex-col gap-2"> <div class="col">
<div class="flex flex-row gap-2 justify-between"> <label class="label">
<label class="label flex flex-col gap-2 grow"> <span class="mt-4">{{ .lang.General.listenAddress }}</span>
<span>{{ .lang.General.listenAddress }}</span> <input type="url" class="input ~neutral @low mt-4 mb-2" id="ui-host" value="0.0.0.0">
<input type="url" class="input ~neutral @low" id="ui-host" value="0.0.0.0">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="row switch">
<span>{{ .lang.Strings.port }}</span> <input type="checkbox" class="mr-2" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span>
<input type="number" class="input ~neutral @low" id="ui-port" value="8056">
</label> </label>
</div> <p class="support mb-2 mt-1">{{ .lang.General.useHTTPSNotice }}</p>
<label class="label flex flex-col gap-2"> <label class="label">
<div class="switch"><input type="checkbox" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span></div> <span class="mt-4">{{ .lang.General.pathToCertificate }}</span>
<p class="support">{{ .lang.General.useHTTPSNotice }}</p> <input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_cert">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.General.pathToCertificate }}</span> <span class="mt-4">{{ .lang.General.pathToKeyFile }}</span>
<input type="text" class="input ~neutral @low" id="advanced-tls_cert"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_key">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.pathToKeyFile }}</span>
<input type="text" class="input ~neutral @low" id="advanced-tls_key">
</label> </label>
<span class="heading">{{ .lang.Updates.title }}</span> <span class="heading">{{ .lang.Updates.title }}</span>
<p class="content" id="updates-description"></p> <p class="content my-2" id="updates-description"></p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Updates.updateChannel }}</span> <span>{{ .lang.Updates.updateChannel }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="updates-channel"> <select id="updates-channel">
<option value="stable">{{ .lang.Updates.stable }}</option> <option value="stable">{{ .lang.Updates.stable }}</option>
<option value="unstable">{{ .lang.Updates.unstable }}</option> <option value="unstable">{{ .lang.Updates.unstable }}</option>
@ -105,246 +99,188 @@
</div> </div>
</label> </label>
</div> </div>
<div class="flex flex-col gap-2"> <div class="col">
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.General.httpsPort }}</span> <span class="mt-4">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low" id="advanced-tls_port" value="8057"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="ui-port" value="8056">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span> <span class="mt-4">{{ .lang.General.httpsPort }}</span>
<input type="text" class="input ~neutral @low" id="ui-url_base" placeholder="/mysubfolder"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_port" value="8057">
<p class="support">{{ .lang.General.urlBaseNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.General.externalURL }}</span> <span class="mt-4">{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder"> <input type="url" class="input ~neutral @low mt-4" id="ui-url_base">
<p class="support">{{ .lang.General.externalURLNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.General.urlBaseNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.theme }}</span> <span>{{ .lang.Strings.theme }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="ui-theme"> <select id="ui-theme">
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option> <option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option> <option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
</select> </select>
</div> </div>
</label> </label>
<span class="heading">{{ .lang.Proxy.title }}</span>
<p class="content" id="proxy-description">{{ .lang.Proxy.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Proxy.protocol }}</span>
<div class="select ~neutral @low">
<select id="advanced-proxy_protocol">
<option value="http">HTTP</option>
<option value="socks">SOCKS5</option>
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Proxy.address }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_address">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_user">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_password">
</label>
</div> </div>
</div> </div>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Login.title }}</span> <span class="heading">{{ .lang.Login.title }}</span>
<p class="content">{{ .lang.Login.description }}</p> <p class="content my-2">{{ .lang.Login.description }}</p>
<div class="flex flex-col gap-2"> <div class="pl-4">
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span></div> <input type="radio" class="mr-2" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span>
</label> </label>
<div class="pl-4 flex flex-col gap-2"> <label class="row switch pl-4 pb-4">
<label class="label flex flex-col gap-2"> <input type="checkbox" class="mr-2" id="ui-admin_only" checked><span>{{ .lang.Login.adminOnly }}</span>
<div class="switch"><input type="checkbox" id="ui-admin_only" checked><span>{{ .lang.Login.adminOnly }}</span></div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="row switch pl-4 pb-2">
<div class="switch"><input type="checkbox" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span></div> <input type="checkbox" class="mr-2" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span>
<p class="support" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
</label> </label>
<p class="support pb-4 pl-4 mt-1" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
</label>
<p class="support pb-4 pl-4 mt-1">{{ .lang.Login.authorizeManualUserPageNotice }}</p>
</div> </div>
</div> <div id="login-manual">
<div class="flex flex-col gap-2"> <label class="label">
<label class="label flex flex-col gap-2"> <span class="mt-4">{{ .lang.Strings.username }}</span>
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span></div> <input type="text" id="ui-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
</label> </label>
<p class="support">{{ .lang.Login.authorizeManualUserPageNotice }}</p> <label class="label">
</div>
<div class ="flex flex-col gap-2" id="login-manual">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" id="ui-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span> <span>{{ .lang.Strings.password }}</span>
<input type="password" id="ui-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}"> <input type="password" id="ui-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span> <span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
<input type="email" id="ui-email" class="input ~neutral @low" placeholder="email@address"> <input type="email" id="ui-email" class="input ~neutral @low mt-4" placeholder="email@address">
<span class="support">{{ .lang.Login.emailNotice }}</span> <span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label> </label>
</div> </div>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.JellyfinEmby.title }}</span> <span class="heading">{{ .lang.JellyfinEmby.title }}</span>
<p class="content">{{ .lang.JellyfinEmby.description }}</p> <p class="content my-2">{{ .lang.JellyfinEmby.description }}</p>
<div class="flex flex-row gap-2 justify-between"> <div class="row">
<div class="flex flex-col gap-2 grow"> <div class="col">
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.serverType }}</span> <span>{{ .lang.Strings.serverType }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4">
<select id="jellyfin-type"> <select id="jellyfin-type">
<option value="jellyfin">Jellyfin</option> <option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option> <option value="emby">Emby</option>
</select> </select>
</div> </div>
<p class="support">{{ .lang.JellyfinEmby.embyNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.embyNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span> <span class="mt-4">{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral @low" id="jellyfin-substitute_jellyfin_strings"> <input type="text" class="input ~neutral @low mt-4" id="jellyfin-substitute_jellyfin_strings">
<p class="support">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.username }}</span> <span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" id="jellyfin-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}"> <input type="text" id="jellyfin-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.password }}</span> <span>{{ .lang.Strings.password }}</span>
<input type="password" id="jellyfin-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}"> <input type="password" id="jellyfin-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
</label> </label>
</div> </div>
<div class="flex flex-col gap-2 grow "> <div class="col">
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span> <span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
<input type="url" class="input ~neutral @low" id="jellyfin-server" placeholder="http://jellyf.in:80"> <input type="url" class="input ~neutral @low mt-4 mb-2" id="jellyfin-server" placeholder="http://jellyf.in:80">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span> <span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
<input type="url" class="input ~neutral @low" id="jellyfin-public_server" placeholder="https://jellyf.in"> <input type="url" class="input ~neutral @low mt-4" id="jellyfin-public_server" placeholder="https://jellyf.in">
<p class="support">{{ .lang.JellyfinEmby.addressExternalNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
</label> </label>
</div> </div>
</div> </div>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div class="flex flex-row gap-2"> <div>
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span> <span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
<span class="button ~urge @low next" disabled>{{ .lang.Strings.next }}</span> <span class="button ~urge @low next" disabled>{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Ombi.title }}</span> <span class="heading">{{ .lang.Ombi.title }}</span>
<p class="content">{{ .lang.Ombi.description }}</p> <p class="content my-2">{{ .lang.Ombi.description }}</p>
<aside class="aside ~warning" id="ombi-stability-warning">{{ .lang.Ombi.stabilityWarning }}</aside> <label class="row switch pb-4">
<label class="label flex flex-col gap-2"> <input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
<div class="switch"><input type="checkbox" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.serverAddress }}</span> <span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="ombi-server" placeholder="ombi.jellyf.in"> <input type="url" class="input ~neutral @low mt-4 mb-2" id="ombi-server" placeholder="ombi.jellyf.in">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.apiKey }}</span> <span class="mt-4">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="ombi-api_key"> <input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support">{{ .lang.Ombi.apiKeyNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label> </label>
<span class="heading">{{ .lang.Jellyseerr.title }}</span> <section class="section ~neutral banner footer flex-expand middle">
<p class="content">{{ .lang.Jellyseerr.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="jellyseerr-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="jellyseerr-server" placeholder="https://jellyseerr.jellyf.in:5055">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="jellyseerr-api_key">
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="jellyseerr-import_existing" checked><span>{{ .lang.Jellyseerr.importExisting }}</span></div>
<p class="support">{{ .lang.Jellyseerr.importExistingDescription }}</p>
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.UserPage.title }}</span> <span class="heading">{{ .lang.UserPage.title }}</span>
<p class="content">{{ .lang.UserPage.description }}</p> <p class="content my-2">{{ .lang.UserPage.description }}</p>
<p class="content">{{ .lang.UserPage.customizeMessages }}</p> <p class="content my-2">{{ .lang.UserPage.customizeMessages }}</p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
<p class="support">{{ .lang.UserPage.requiredSettings }}</p>
</label> </label>
</section> <p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Messages.title }}</span> <span class="heading">{{ .lang.Messages.title }}</span>
<p class="content" id="messages-description"></p> <p class="content my-2" id="messages-description"></p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Email.dateFormat }}</span> <span class="mt-4">{{ .lang.Email.dateFormat }}</span>
<input type="text" class="input ~neutral @low" id="email-date_format" value="%d/%m/%y"> <input type="text" class="input ~neutral @low mt-4" id="email-date_format" value="%d/%m/%y">
<p class="support" id="email-dateformat-notice"></p> <p class="support mb-2 mt-1" id="email-dateformat-notice"></p>
</label> </label>
<div class="flex flex-col gap-2"> <div>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div> <input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span></div> <input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span>
</label> </label>
</div> </div>
<div id="email-sect" class="flex flex-row gap-2 justify-between"> <div id="email-sect">
<div class="flex flex-col gap-2">
<span class="heading">{{ .lang.Email.title }}</span> <span class="heading">{{ .lang.Email.title }}</span>
<p class="content" id="email-description"></p> <p class="content my-2" id="email-description"></p>
<label class="label flex flex-col gap-2"> <div class="row">
<div class="col">
<label class="label">
<span>{{ .lang.Email.method }}</span> <span>{{ .lang.Email.method }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="email-method"> <select id="email-method">
<option value="">{{ .lang.Strings.disabled }}</option> <option value="">{{ .lang.Strings.disabled }}</option>
<option value="smtp">SMTP</option> <option value="smtp">SMTP</option>
@ -352,225 +288,221 @@
</select> </select>
</div> </div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="row switch">
<div class="switch"><input type="checkbox" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span></div> <input type="checkbox" class="mr-2" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span>
<p class="support">{{ .lang.Email.useEmailAsUsernameNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Email.fromAddress }}</span> <span class="mt-4">{{ .lang.Email.fromAddress }}</span>
<input type="email" class="input ~neutral @low" id="email-address" placeholder="mail@jellyf.in"> <input type="email" class="input ~neutral @low mt-4 mb-2" id="email-address" placeholder="mail@jellyf.in">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Email.senderName }}</span> <span class="mt-4">{{ .lang.Email.senderName }}</span>
<input type="text" class="input ~neutral @low" id="email-from" value="Jellyfin"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="email-from" value="Jellyfin">
</label> </label>
</div> </div>
<div id="email-smtp" class="flex flex-col gap-2 min-w-[40%]"> <div class="col">
<p class="text-2xl font-semibold">SMTP</p> <div id="email-smtp">
<label class="label flex flex-col gap-2"> <p class="text-2xl font-semibold mb-2">SMTP</p>
<label class="label">
<span>{{ .lang.Email.encryption }}</span> <span>{{ .lang.Email.encryption }}</span>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="smtp-encryption"> <select id="smtp-encryption">
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option> <option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option> <option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
</select> </select>
</div> </div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.serverAddress }}</span> <span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="smtp-server" placeholder="smtp.jellyf.in"> <input type="url" class="input ~neutral @low mt-4 mb-2" id="smtp-server" placeholder="smtp.jellyf.in">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.port }}</span> <span class="mt-4">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low" id="smtp-port" placeholder="587"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="smtp-port" placeholder="587">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.username }}</span> <span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low" id="smtp-username"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="smtp-username">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.password }}</span> <span class="mt-4">{{ .lang.Strings.password }}</span>
<input type="password" class="input ~neutral @low" id="smtp-password"> <input type="password" class="input ~neutral @low mt-4 mb-2" id="smtp-password">
</label> </label>
</div> </div>
<div id="email-mailgun" class="flex flex-col gap-2 min-w-[40%]"> <div id="email-mailgun">
<p class="text-2xl font-semibold">Mailgun</p> <p class="text-2xl font-semibold mb-2">Mailgun</p>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Email.mailgunApiURL }}</span> <span class="mt-4">{{ .lang.Email.mailgunApiURL }}</span>
<input type="url" class="input ~neutral @low" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages"> <input type="url" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.apiKey }}</span> <span class="mt-4">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="mailgun-api_key"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_key">
</label> </label>
</div> </div>
</div> </div>
</section> </div>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center"> </div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused related-to-email"> <div class="card ~neutral @low mb-2 unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Notifications.title }}</span> <span class="heading">{{ .lang.Notifications.title }}</span>
<p class="content">{{ .lang.Notifications.description }}</p> <p class="content my-2">{{ .lang.Notifications.description }}</p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<span class="heading">{{ .lang.WelcomeEmails.title }}</span> <span class="heading">{{ .lang.WelcomeEmails.title }}</span>
<p class="content">{{ .lang.WelcomeEmails.description }}</p> <p class="content my-2">{{ .lang.WelcomeEmails.description }}</p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label> </label>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused related-to-email"> <div class="card ~neutral @low mb-2 unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.InviteEmails.title }}</span> <span class="heading">{{ .lang.InviteEmails.title }}</span>
<p class="content">{{ .lang.InviteEmails.description }}</p> <p class="content my-2">{{ .lang.InviteEmails.description }}</p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.URL }}</span>
<input type="text" class="input ~neutral @low" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}"> <input type="url" class="input ~neutral @low mt-4 mb-2" id="invite_emails-url_base" placeholder="https://accounts.jellyf.in/invite">
</label> </label>
</section> <label class="label">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center"> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div id="password-resets" class="card sectioned ~neutral @low unfocused related-to-email"> <div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.PasswordResets.title }}</span> <span class="heading">{{ .lang.PasswordResets.title }}</span>
<p class="content">{{ .lang.PasswordResets.description }}</p> <p class="content my-2">{{ .lang.PasswordResets.description }}</p>
<p class="content" id="password_resets-more-info">{{ .lang.PasswordResets.moreInfo }}</p> <label class="row switch pb-4">
<label class="label flex flex-col gap-2"> <input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
<div class="switch"><input type="checkbox" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordResets.pathToJellyfin }}</span> <span class="mt-4">{{ .lang.PasswordResets.pathToJellyfin }}</span>
<input type="text" class="input ~neutral @low" id="password_resets-watch_directory" placeholder="/config/jellyfin"> <input type="text" class="input ~neutral @low mt-4" id="password_resets-watch_directory" placeholder="/config/jellyfin">
<p class="support">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="switch">
<div class="switch"><input type="checkbox" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span></div> <input type="checkbox" class="mr-2" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span>
<p class="support">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p> <p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="switch">
<div class="switch"><input type="checkbox" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span></div> <input type="checkbox" class="mr-2" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span>
<p class="support">{{ .lang.PasswordResets.setPasswordNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.PasswordResets.setPasswordNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordResets.resetLinksLanguage }}</span> <p class="mt-4">{{ .lang.PasswordResets.resetLinksLanguage }}</p>
<div class="select ~neutral @low"> <div class="select ~neutral @low mt-4 mb-2">
<select id="password_resets-language"> <select id="password_resets-language">
</select> </select>
</div> </div>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="row label">
<span>{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label> </label>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.PasswordValidation.title }}</span> <span class="heading">{{ .lang.PasswordValidation.title }}</span>
<p class="content">{{ .lang.PasswordValidation.description }}</p> <p class="content my-2">{{ .lang.PasswordValidation.description }}</p>
<label class="label flex flex-col gap-2"> <label class="row switch pb-4">
<div class="switch"><input type="checkbox" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div> <input type="checkbox" class="mr-2" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordValidation.length }}</span> <span class="mt-4">{{ .lang.PasswordValidation.length }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-min_length" value="8"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-min_length" value="8">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordValidation.uppercase }}</span> <span class="mt-4">{{ .lang.PasswordValidation.uppercase }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-upper" value="1"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-upper" value="1">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordValidation.lowercase }}</span> <span class="mt-4">{{ .lang.PasswordValidation.lowercase }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-lower" value="0"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-lower" value="0">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordValidation.numbers }}</span> <span class="mt-4">{{ .lang.PasswordValidation.numbers }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-number" value="0"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-number" value="0">
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.PasswordValidation.special }}</span> <span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-special" value="0"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
</label> </label>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.HelpMessages.title }}</span> <span class="heading">{{ .lang.HelpMessages.title }}</span>
<p class="content">{{ .lang.HelpMessages.description }}</p> <p class="content my-2">{{ .lang.HelpMessages.description }}</p>
<p class="content">{{ .lang.HelpMessages.markdownMessageNotice }}</p> <label class="label">
<label class="label flex flex-col gap-2"> <span class="mt-4">{{ .lang.HelpMessages.contactMessage }}</span>
<span>{{ .lang.HelpMessages.contactMessage }}</span> <input type="text" class="input ~neutral @low mt-4" id="ui-contact_message">
<input type="text" class="input ~neutral @low" id="ui-contact_message"> <p class="support mb-2 mt-1">{{ .lang.HelpMessages.contactMessageNotice }}</p>
<p class="support">{{ .lang.HelpMessages.contactMessageNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.HelpMessages.helpMessage }}</span> <span class="mt-4">{{ .lang.HelpMessages.helpMessage }}</span>
<input type="text" class="input ~neutral @low" id="ui-help_message"> <input type="text" class="input ~neutral @low mt-4" id="ui-help_message">
<p class="support">{{ .lang.HelpMessages.helpMessageNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.HelpMessages.helpMessageNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label">
<span>{{ .lang.HelpMessages.successMessage }}</span> <span class="mt-4">{{ .lang.HelpMessages.successMessage }}</span>
<input type="text" class="input ~neutral @low" id="ui-success_message"> <input type="text" class="input ~neutral @low mt-4" id="ui-success_message">
<p class="support">{{ .lang.HelpMessages.successMessageNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.HelpMessages.successMessageNotice }}</p>
</label> </label>
<label class="label related-to-email"> <label class="label related-to-email">
<span>{{ .lang.HelpMessages.emailMessage }}</span> <span class="mt-4">{{ .lang.HelpMessages.emailMessage }}</span>
<input type="text" class="input ~neutral @low" id="email-message"> <input type="text" class="input ~neutral @low mt-4" id="email-message">
<p class="support">{{ .lang.HelpMessages.emailMessageNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label> </label>
</section> <section class="section ~neutral banner footer flex-expand middle">
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div> </div>
</section> </section>
</div> </div>
<div class="card sectioned ~neutral @low unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<section class="section flex flex-col gap-2 justify-center items-center"> <div class="row col flex center">
<span class="heading">{{ .lang.EndPage.finished }}</span> <span class="heading">{{ .lang.EndPage.finished }}</span>
<p class="content">{{ .lang.EndPage.restartMessage }}</p> </div>
</section> <div class="row col flex center">
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2"> <p class="content my-2">{{ .lang.EndPage.restartMessage }}</p>
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> </div>
<div class="row col flex center">
<span class="button ~neutral @low back mr-4">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span> <span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span> <span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
</div> </div>
@ -583,3 +515,4 @@
<script src="js/setup.js" type="module"></script> <script src="js/setup.js" type="module"></script>
</body> </body>
</html> </html>

View File

@ -1,4 +1,3 @@
<!DOCTYPE html>
<html lang="en" class="light"> <html lang="en" class="light">
<head> <head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css"> <link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
@ -49,17 +48,11 @@
</div> </div>
{{ if .pwrEnabled }} {{ if .pwrEnabled }}
<div id="modal-pwr" class="modal"> <div id="modal-pwr" class="modal">
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.resetPassword }}</span> <span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2"> <p class="content my-2">
{{ if .linkResetEnabled }} {{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLinkStart }} {{ .strings.resetPasswordThroughLink }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }} {{ else }}
{{ .strings.resetPasswordThroughJellyfin }} {{ .strings.resetPasswordThroughJellyfin }}
{{ end }} {{ end }}
@ -80,7 +73,7 @@
{{ template "login-modal.html" . }} {{ template "login-modal.html" . }}
{{ template "account-linking.html" . }} {{ template "account-linking.html" . }}
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="top-2 left-2 absolute"> <div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -103,17 +96,17 @@
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span> <span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span> <span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div> </div>
<div class="top-2 right-2 absolute"> <div class="top-4 right-4 absolute">
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a> <a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
</div> </div>
<div class="page-container m-2 lg:my-20 lg:mx-64 unfocused"> <div class="page-container unfocused">
<div class="card @low dark:~d_neutral mb-4" id="card-user"> <div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span> <span class="heading mb-2"></span>
</div> </div>
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{{ if index . "PageMessageEnabled" }} {{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }} {{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content break-words" id="card-message"> <div class="card @low dark:~d_neutral content" id="card-message">
{{ .PageMessageContent }} {{ .PageMessageContent }}
</div> </div>
{{ end }} {{ end }}
@ -162,8 +155,8 @@
<div> <div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals"> <div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span> <span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside> <aside class="aside ~neutral my-4 col">{{ .strings.referralsDescription }}</aside>
<div class="flex flex-row justify-between gap-2"> <div class="row flex-expand">
<div class="user-referrals-info"></div> <div class="user-referrals-info"></div>
<div class="grid my-2"> <div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button> <button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 40 KiB

0
images/jfa-go-icon.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

0
images/jfa-go-icon.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -1,4 +1,3 @@
//go:build !external
// +build !external // +build !external
package main package main
@ -11,8 +10,6 @@ import (
const binaryType = "internal" const binaryType = "internal"
func BuildTagsExternal() {}
//go:embed data data/html data/web data/web/css data/web/js //go:embed data data/html data/web data/web/css data/web/js
var loFS embed.FS var loFS embed.FS

View File

@ -1,82 +0,0 @@
package main
import (
"strconv"
"time"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil {
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return
}
if imported {
app.debug.Printf(lm.ImportJellyseerrUser, jfID, user.ID)
}
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.debug.Printf(lm.FailedGetJellyseerrNotificationPrefs, jfID, err)
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
email, ok := app.storage.GetEmailsKey(jfID)
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
}
if discordEnabled {
dcUser, ok := app.storage.GetDiscordKey(jfID)
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
}
}
if telegramEnabled {
tgUser, ok := app.storage.GetTelegramKey(jfID)
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
u, _ := app.storage.GetTelegramKey(jfID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
}
}
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
}
func (app *appContext) SynchronizeJellyseerrUsers() {
users, err := app.jf.GetUsers(false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return
}
// I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway.
for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID)
}
}
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import")
return d
}

View File

@ -1,9 +0,0 @@
module github.com/hrfee/jfa-go/jellyseerr
replace github.com/hrfee/jfa-go/common => ../common
go 1.18
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect

View File

@ -1,2 +0,0 @@
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o=
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ=

View File

@ -1,423 +0,0 @@
package jellyseerr
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
co "github.com/hrfee/jfa-go/common"
)
const (
API_SUFFIX = "/api/v1"
BogusIdentifier = "123412341234123456"
)
// Jellyseerr represents a running Jellyseerr instance.
type Jellyseerr struct {
server, key string
header map[string]string
httpClient *http.Client
userCache map[string]User // Map of jellyfin IDs to users
cacheExpiry time.Time
cacheLength time.Duration
timeoutHandler co.TimeoutHandler
LogRequestBodies bool
AutoImportUsers bool
}
// NewJellyseerr returns an Ombi object.
func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellyseerr {
if !strings.HasSuffix(server, API_SUFFIX) {
server = server + API_SUFFIX
}
return &Jellyseerr{
server: server,
key: key,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
header: map[string]string{
"X-Api-Key": key,
},
cacheLength: time.Duration(30) * time.Minute,
cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler,
userCache: map[string]User{},
LogRequestBodies: false,
}
}
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (js *Jellyseerr) SetTransport(t *http.Transport) {
js.httpClient.Transport = t
}
func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), uri)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range js.header {
req.Header.Add(name, value)
}
if headers != nil {
for name, value := range headers {
req.Header.Add(name, value)
}
}
resp, err := js.httpClient.Do(req)
err = co.GenericErr(resp.StatusCode, err)
defer js.timeoutHandler()
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = js.decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if err != nil {
var msg ErrorDTO
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg.Message != "" {
err = fmt.Errorf("got %d: %s", resp.StatusCode, msg.Message)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func (js *Jellyseerr) decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}
func (js *Jellyseerr) get(uri string, data any, params url.Values) (string, int, error) {
return js.req(http.MethodGet, uri, data, params, nil, true)
}
func (js *Jellyseerr) post(uri string, data any, response bool) (string, int, error) {
return js.req(http.MethodPost, uri, data, url.Values{}, nil, response)
}
func (js *Jellyseerr) put(uri string, data any, response bool) (string, int, error) {
return js.req(http.MethodPut, uri, data, url.Values{}, nil, response)
}
func (js *Jellyseerr) delete(uri string, data any) (int, error) {
_, status, err := js.req(http.MethodDelete, uri, data, url.Values{}, nil, false)
return status, err
}
func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
params := map[string]interface{}{
"jellyfinUserIds": jfIDs,
}
resp, _, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
var data []User
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
for _, u := range data {
if u.JellyfinUserID != "" {
js.userCache[u.JellyfinUserID] = u
}
}
return data, err
}
func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) {
return nil
}
js.cacheExpiry = time.Now().Add(js.cacheLength)
pageCount := 1
pageIndex := 0
for {
res, err := js.getUserPage(pageIndex)
if err != nil {
return err
}
for _, u := range res.Results {
if u.JellyfinUserID == "" {
continue
}
js.userCache[u.JellyfinUserID] = u
}
pageCount = res.Page.Pages
pageIndex++
if pageIndex >= pageCount {
break
}
}
return nil
}
func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
params := url.Values{}
params.Add("take", "30")
params.Add("skip", strconv.Itoa(page*30))
params.Add("sort", "created")
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params)
}
resp, _, err := js.get(js.server+"/user", nil, params)
var data GetUsersDTO
if err == nil {
err = json.Unmarshal([]byte(resp), &data)
}
return data, err
}
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
u, _, err := js.GetOrImportUser(jfID)
return u, err
}
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed. Also returns whether the user was imported or not,
func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err error) {
imported = false
u, err = js.GetExistingUser(jfID)
if err == nil {
return
}
var users []User
users, err = js.ImportFromJellyfin(jfID)
if err != nil {
return
}
if len(users) != 0 {
u = users[0]
err = nil
return
}
err = fmt.Errorf("user not found or imported")
return
}
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) {
js.getUsers()
ok := false
err = nil
if u, ok = js.userCache[jfID]; ok {
return
}
js.cacheExpiry = time.Now()
js.getUsers()
if u, ok = js.userCache[jfID]; ok {
err = nil
return
}
err = fmt.Errorf("user not found")
return
}
func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers {
return js.MustGetUser(jfID)
}
return js.GetExistingUser(jfID)
}
func (js *Jellyseerr) Me() (User, error) {
resp, _, err := js.get(js.server+"/auth/me", nil, url.Values{})
var data User
data.ID = -1
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID)
if err != nil {
return data.Permissions, err
}
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
if err != nil {
return data.Permissions, err
}
err = json.Unmarshal([]byte(resp), &data)
return data.Permissions, err
}
func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
if err != nil {
return err
}
u.Permissions = perm
js.userCache[jfID] = u
return nil
}
func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
if err != nil {
return err
}
u.UserTemplate = tmpl
js.userCache[jfID] = u
return nil
}
func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if _, ok := conf[FieldEmail]; ok {
return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead")
}
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
if err != nil {
return err
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil
}
func (js *Jellyseerr) DeleteUser(jfID string) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, err = js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
if err != nil {
return err
}
delete(js.userCache, jfID)
return err
}
func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
u, err := js.getUser(jfID)
if err != nil {
return Notifications{}, err
}
return js.GetNotificationPreferencesByID(u.ID)
}
func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
var data Notifications
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl NotificationsTemplate) error {
// This behaviour is not desired, this being all-zero means no notifications, which is a settings state we'd want to store!
/* if tmpl.NotifTypes.Empty() {
tmpl.NotifTypes = nil
}*/
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
if err != nil {
return err
}
return nil
}
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
if err != nil {
return err
}
return nil
}
func (js *Jellyseerr) GetUsers() (map[string]User, error) {
err := js.getUsers()
return js.userCache, err
}
func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
resp, _, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
var data User
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
if err != nil {
return err
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil
}

View File

@ -1,69 +0,0 @@
package jellyseerr
import (
"testing"
"github.com/hrfee/jfa-go/common"
)
const (
API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng=="
URI = "http://localhost:5055"
PERM = 2097184
)
func client() *Jellyseerr {
return NewJellyseerr(URI, API_KEY, common.NewTimeoutHandler("Jellyseerr", URI, false))
}
func TestMe(t *testing.T) {
js := client()
u, err := js.Me()
if err != nil {
t.Fatalf("returned error %+v", err)
}
if u.ID < 0 {
t.Fatalf("returned no user %+v\n", u)
}
}
/* func TestImportFromJellyfin(t *testing.T) {
js := client()
list, err := js.ImportFromJellyfin("6b75e189efb744f583aa2e8e9cee41d3")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if len(list) == 0 {
t.Fatalf("returned no users")
}
} */
func TestMustGetUser(t *testing.T) {
js := client()
u, err := js.MustGetUser("8c9d25c070d641cd8ad9cf825f622a16")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if u.ID < 0 {
t.Fatalf("returned no users")
}
}
func TestSetPermissions(t *testing.T) {
js := client()
err := js.SetPermissions("6b75e189efb744f583aa2e8e9cee41d3", PERM)
if err != nil {
t.Fatalf("returned error %+v", err)
}
}
func TestGetPermissions(t *testing.T) {
js := client()
perm, err := js.GetPermissions("6b75e189efb744f583aa2e8e9cee41d3")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if perm != PERM {
t.Fatalf("got unexpected perm code %d", perm)
}
}

View File

@ -1,136 +0,0 @@
package jellyseerr
import "time"
type UserField string
const (
FieldDisplayName UserField = "displayName"
FieldEmail UserField = "email"
)
type User struct {
UserTemplate // Note: You can set this with User.UserTemplate = value.
UserType int64 `json:"userType,omitempty"`
Warnings []any `json:"warnings,omitempty"`
ID int64 `json:"id,omitempty"`
Email string `json:"email,omitempty"`
PlexUsername string `json:"plexUsername,omitempty"`
JellyfinUsername string `json:"jellyfinUsername,omitempty"`
Username string `json:"username,omitempty"`
RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"`
PlexID string `json:"plexId,omitempty"`
JellyfinUserID string `json:"jellyfinUserId,omitempty"`
JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"`
JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"`
PlexToken string `json:"plexToken,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
RequestCount int64 `json:"requestCount,omitempty"`
DisplayName string `json:"displayName,omitempty"`
}
func (u User) Name() string {
var n string
if u.Username != "" {
n = u.Username
} else if u.JellyfinUsername != "" {
n = u.JellyfinUsername
}
if u.DisplayName != "" {
n += " (" + u.DisplayName + ")"
}
return n
}
type UserTemplate struct {
Permissions Permissions `json:"permissions,omitempty"`
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
}
type PageInfo struct {
Pages int `json:"pages,omitempty"`
PageSize int `json:"pageSize,omitempty"`
Results int `json:"results,omitempty"`
Page int `json:"page,omitempty"`
}
type GetUsersDTO struct {
Page PageInfo `json:"pageInfo,omitempty"`
Results []User `json:"results,omitempty"`
}
type permissionsDTO struct {
Permissions Permissions `json:"permissions,omitempty"`
}
type Permissions int
type NotificationTypes struct {
Discord int64 `json:"discord"`
Email int64 `json:"email"`
Pushbullet int64 `json:"pushbullet"`
Pushover int64 `json:"pushover"`
Slack int64 `json:"slack"`
Telegram int64 `json:"telegram"`
Webhook int64 `json:"webhook"`
Webpush int64 `json:"webpush"`
}
/* func (nt *NotificationTypes) Empty() bool {
return nt.Discord == 0 && nt.Email == 0 && nt.Pushbullet == 0 && nt.Pushover == 0 && nt.Slack == 0 && nt.Telegram == 0 && nt.Webhook == 0 && nt.Webpush == 0
} */
type NotificationsField string
const (
FieldDiscord NotificationsField = "discordId"
FieldTelegram NotificationsField = "telegramChatId"
FieldEmailEnabled NotificationsField = "emailEnabled"
FieldDiscordEnabled NotificationsField = "discordEnabled"
FieldTelegramEnabled NotificationsField = "telegramEnabled"
)
type Notifications struct {
NotificationsTemplate
PgpKey any `json:"pgpKey,omitempty"`
DiscordID string `json:"discordId,omitempty"`
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
TelegramChatID string `json:"telegramChatId,omitempty"`
}
type NotificationsTemplate struct {
EmailEnabled bool `json:"emailEnabled,omitempty"`
DiscordEnabled bool `json:"discordEnabled,omitempty"`
DiscordEnabledTypes int64 `json:"discordEnabledTypes,omitempty"`
PushoverSound any `json:"pushoverSound,omitempty"`
TelegramEnabled bool `json:"telegramEnabled,omitempty"`
TelegramSendSilently any `json:"telegramSendSilently,omitempty"`
WebPushEnabled bool `json:"webPushEnabled,omitempty"`
NotifTypes NotificationTypes `json:"notificationTypes"`
}
type MainUserSettings struct {
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
DiscordID string `json:"discordId,omitempty"`
Locale string `json:"locale,omitempty"`
Region string `json:"region,omitempty"`
OriginalLanguage any `json:"originalLanguage,omitempty"`
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"`
WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"`
}
type ErrorDTO struct {
Message string `json:"message,omitempty"`
}

41
lang.go
View File

@ -1,7 +1,5 @@
package main package main
import "github.com/hrfee/jfa-go/common"
type langMeta struct { type langMeta struct {
Name string `json:"name"` Name string `json:"name"`
// Language to fall back on if strings are missing. Defaults to en-us. // Language to fall back on if strings are missing. Defaults to en-us.
@ -15,11 +13,11 @@ type quantityString struct {
type adminLangs map[string]adminLang type adminLangs map[string]adminLang
func (ls *adminLangs) getOptions() []common.Option { func (ls *adminLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts
@ -44,11 +42,11 @@ type adminLang struct {
type userLangs map[string]userLang type userLangs map[string]userLang
func (ls *userLangs) getOptions() []common.Option { func (ls *userLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts
@ -67,11 +65,11 @@ type userLang struct {
type pwrLangs map[string]pwrLang type pwrLangs map[string]pwrLang
func (ls *pwrLangs) getOptions() []common.Option { func (ls *pwrLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts
@ -84,11 +82,11 @@ type pwrLang struct {
type emailLangs map[string]emailLang type emailLangs map[string]emailLang
func (ls *emailLangs) getOptions() []common.Option { func (ls *emailLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts
@ -103,7 +101,6 @@ type emailLang struct {
UserDeleted langSection `json:"userDeleted"` UserDeleted langSection `json:"userDeleted"`
UserDisabled langSection `json:"userDisabled"` UserDisabled langSection `json:"userDisabled"`
UserEnabled langSection `json:"userEnabled"` UserEnabled langSection `json:"userEnabled"`
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
InviteEmail langSection `json:"inviteEmail"` InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"` WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"` EmailConfirmation langSection `json:"emailConfirmation"`
@ -119,12 +116,10 @@ type setupLang struct {
EndPage langSection `json:"endPage"` EndPage langSection `json:"endPage"`
General langSection `json:"general"` General langSection `json:"general"`
Updates langSection `json:"updates"` Updates langSection `json:"updates"`
Proxy langSection `json:"proxy"`
Language langSection `json:"language"` Language langSection `json:"language"`
Login langSection `json:"login"` Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"` JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"` Ombi langSection `json:"ombi"`
Jellyseerr langSection `json:"jellyseerr"`
Email langSection `json:"email"` Email langSection `json:"email"`
Messages langSection `json:"messages"` Messages langSection `json:"messages"`
Notifications langSection `json:"notifications"` Notifications langSection `json:"notifications"`
@ -137,11 +132,11 @@ type setupLang struct {
JSON string JSON string
} }
func (ls *setupLangs) getOptions() []common.Option { func (ls *setupLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts
@ -154,11 +149,11 @@ type telegramLang struct {
Strings langSection `json:"strings"` Strings langSection `json:"strings"`
} }
func (ts *telegramLangs) getOptions() []common.Option { func (ts *telegramLangs) getOptions() [][2]string {
opts := make([]common.Option, len(*ts)) opts := make([][2]string, len(*ts))
i := 0 i := 0
for key, lang := range *ts { for key, lang := range *ts {
opts[i] = common.Option{key, lang.Meta.Name} opts[i] = [2]string{key, lang.Meta.Name}
i++ i++
} }
return opts return opts

View File

@ -32,12 +32,12 @@
"before": "قبل", "before": "قبل",
"user": "مستخدم", "user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم", "userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.", "userExpiryDescription": "",
"aboutProgram": "حول", "aboutProgram": "حول",
"version": "إصدار", "version": "إصدار",
"commitNoun": "فرض", "commitNoun": "تعديل",
"newUser": "مستخدم جديد", "newUser": "مستخدم جديد",
"profile": "حساب تعريفي", "profile": "ملف",
"unknown": "غير معروف", "unknown": "غير معروف",
"label": "وسم", "label": "وسم",
"logs": "السجلات", "logs": "السجلات",
@ -63,19 +63,19 @@
"markdownSupported": "", "markdownSupported": "",
"modifySettings": "", "modifySettings": "",
"modifySettingsDescription": "", "modifySettingsDescription": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه", "applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "ارسال رساله اشعار", "sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "تم حذف حسابك.", "sendDeleteNotifiationExample": "",
"settingsRestart": "اعاده تشغيل", "settingsRestart": "",
"settingsRestarting": "اعاده التشغيل…", "settingsRestarting": "",
"settingsRestartRequired": "يجب اعاده التشغيل", "settingsRestartRequired": "",
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟", "settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا", "settingsApplyRestartLater": "",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل", "settingsApplyRestartNow": "",
"settingsApplied": "تم تطبيق الاعدادات.", "settingsApplied": "",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.", "settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.", "settingsRequiredOrRestartMessage": "",
"settingsSave": "حفظ", "settingsSave": "",
"ombiProfile": "", "ombiProfile": "",
"ombiUserDefaultsDescription": "", "ombiUserDefaultsDescription": "",
"userProfiles": "", "userProfiles": "",
@ -117,15 +117,7 @@
"userPageLogin": "", "userPageLogin": "",
"userPagePage": "", "userPagePage": "",
"buildTime": "", "buildTime": "",
"builtBy": "", "builtBy": ""
"activity": "الانشطه",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "", "changedEmailAddress": "",

View File

@ -1,229 +0,0 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"invites": "Pozvánky",
"invite": "Pozvat",
"accounts": "Účty",
"settings": "Nastavení",
"inviteMonths": "Měsíce",
"inviteDays": "Dny",
"inviteHours": "Hodiny",
"inviteMinutes": "Minut",
"inviteNumberOfUses": "Počet použití",
"inviteDuration": "Doba trvání pozvánky",
"warning": "Varování",
"inviteInfiniteUsesWarning": "pozvánky s nekonečným využitím mohou být zneužity",
"inviteSendToEmail": "Poslat komu",
"create": "Vytvořit",
"apply": "Aplikovat",
"select": "Vybrat",
"name": "Název",
"date": "Datum",
"setExpiry": "Nastavit expiraci",
"updates": "Aktualizace",
"update": "Aktualizace",
"download": "Stažení",
"search": "Vyhledávání",
"advancedSettings": "Pokročilé nastavení",
"lastActiveTime": "Naposled aktivní",
"from": "Z",
"after": "Po",
"before": "Před",
"user": "Uživatel",
"userExpiry": "Vypršení platnosti",
"userExpiryDescription": "Zadanou dobu po každé registraci jfa-go smaže/zakáže účet. Toto chování můžete změnit v nastavení.",
"aboutProgram": "O",
"version": "Verze",
"commitNoun": "Zavázat se",
"newUser": "Nový uživatel",
"profile": "Profil",
"unknown": "Neznámý",
"label": "Štítek",
"userLabel": "Uživatelský štítek",
"userLabelDescription": "Štítek, který se použije pro uživatele vytvořené pomocí této pozvánky.",
"logs": "Protokoly",
"announce": "Oznámit",
"templates": "Šablony",
"subject": "Předmět",
"message": "Zpráva",
"variables": "Proměnné",
"conditionals": "Podmínky",
"preview": "Náhled",
"reset": "Resetovat",
"donate": "Darovat",
"unlink": "Odpojit účet",
"sendPWR": "Odeslat resetování hesla",
"contactThrough": "Kontakt přes:",
"extendExpiry": "Prodloužit platnost",
"sendPWRManual": "Uživatel {n} nemá žádný způsob kontaktu, stisknutím tlačítka Kopírovat získáte odkaz, který mu chcete poslat.",
"sendPWRSuccess": "Odkaz pro resetování hesla byl odeslán.",
"sendPWRSuccessManual": "Pokud jej uživatel neobdržel, stisknutím tlačítka Kopírovat získáte odkaz, který mu můžete ručně odeslat.",
"sendPWRValidFor": "Odkaz je platný 30m.",
"customizeMessages": "Přizpůsobit zprávy",
"customizeMessagesDescription": "Pokud nechcete používat šablony zpráv jfa-go, můžete si vytvořit vlastní pomocí Markdown.",
"markdownSupported": "Markdown je podporován.",
"modifySettings": "Upravit nastavení",
"modifySettingsDescription": "Použít nastavení ze stávajícího profilu nebo je získat přímo od uživatele.",
"enableReferrals": "Povolit doporučení",
"disableReferrals": "Zakázat doporučení",
"enableReferralsDescription": "Poskytněte uživatelům osobní doporučující odkaz podobný pozvánce, kterou můžete poslat přátelům/rodině. Lze je získat ze šablony doporučení v profilu nebo z existující pozvánky.",
"enableReferralsProfileDescription": "Poskytněte uživatelům vytvořeným pomocí tohoto profilu osobní doporučující odkaz podobný pozvánce, aby jej poslali přátelům/rodině. Vytvořte pozvánku s požadovaným nastavením a poté ji vyberte zde. Každé doporučení pak bude založeno na této pozvánce. Po dokončení můžete pozvánku smazat.",
"applyHomescreenLayout": "Použít rozložení domovské obrazovky",
"sendDeleteNotificationEmail": "Odeslat zprávu s upozorněním",
"sendDeleteNotifiationExample": "Váš účet byl smazán.",
"settingsRestart": "Restartovat",
"settingsRestarting": "Restartování…",
"settingsRestartRequired": "Je potřeba restart",
"settingsRestartRequiredDescription": "K použití některých změn, které jste změnili, je nutný restart. Restartovat hned nebo později?",
"settingsApplyRestartLater": "Použít, restartovat později",
"settingsApplyRestartNow": "Použít a restartovat",
"settingsApplied": "Nastavení byla použita.",
"settingsRefreshPage": "Obnovte stránku během několika sekund.",
"settingsRequiredOrRestartMessage": "Poznámka: {n} označuje povinné pole, {n} označuje, že změny vyžadují restart.",
"settingsSave": "Uložit",
"ombiProfile": "Ombi uživatelský profil",
"ombiUserDefaultsDescription": "Vytvořte uživatele Ombi a nakonfigurujte jej, poté jej vyberte níže. Když je tento profil vybrán, jeho nastavení/oprávnění budou uložena a použita pro nové uživatele Ombi vytvořené jfa-go.",
"userProfiles": "Uživatelské profily",
"userProfilesDescription": "Profily se použijí pro uživatele, když si vytvoří účet. Profil zahrnuje přístupová práva ke knihovně a rozvržení domovské obrazovky.",
"userProfilesIsDefault": "Výchozí",
"userProfilesLibraries": "Knihovny",
"addProfile": "Přidat profil",
"addProfileDescription": "Vytvořte uživatele Jellyfin a nakonfigurujte jej, poté jej vyberte níže. Když se tento profil použije na pozvánku, vytvoří se noví uživatelé s nastavením.",
"addProfileNameOf": "Jméno profilu",
"addProfileStoreHomescreenLayout": "Uložit rozložení domovské obrazovky",
"inviteNoUsersCreated": "Ještě žádný!",
"inviteUsersCreated": "Vytvoření uživatelé",
"inviteNoProfile": "Žádný profil",
"inviteDateCreated": "Vytvořeno",
"inviteNoInvites": "Žádný",
"inviteExpiresInTime": "Platnost vyprší za {n}",
"notifyEvent": "Upozornit na:",
"notifyInviteExpiry": "Při vypršení platnosti",
"notifyUserCreation": "Při vytvoření uživatele",
"sendPIN": "Požádejte uživatele, aby robotovi zaslal níže uvedený PIN.",
"searchDiscordUser": "Začněte psát uživatelské jméno Discord a vyhledejte uživatele.",
"findDiscordUser": "Najít uživatele Discordu",
"linkMatrixDescription": "Zadejte uživatelské jméno a heslo uživatele, který chcete použít jako robot. Po odeslání se aplikace restartuje.",
"matrixHomeServer": "Adresa domovského serveru",
"saveAsTemplate": "Uložit jako šablonu",
"deleteTemplate": "Smazat šablonu",
"templateEnterName": "Zadejte název pro uložení této šablony.",
"accessJFA": "Přístup k jfa-go",
"accessJFASettings": "Nelze změnit, protože v Nastavení > Obecné bylo nastaveno \"Pouze správce\" nebo \"Povolit vše\".",
"sortingBy": "Řazení podle",
"filters": "Filtry",
"clickToRemoveFilter": "Kliknutím tento filtr odstraníte.",
"clearSearch": "Vymazat vyhledávání",
"actions": "Akce",
"searchOptions": "Možnosti hledání",
"matchText": "Shoda textu",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Uživatelská stránka: Přihlášení",
"userPagePage": "Uživatelská stránka: Stránka",
"buildTime": "Čas sestavení",
"builtBy": "Postaven",
"loginNotAdmin": "Nejste správce?"
},
"notifications": {
"changedEmailAddress": "Změněna e-mailová adresa uživatele {n}.",
"userCreated": "Uživatel {n} byl vytvořen.",
"createProfile": "Vytvořen profil {n}.",
"saveSettings": "Nastavení byla uložena",
"saveEmail": "Email byl uložen.",
"sentAnnouncement": "Oznámení odesláno.",
"savedAnnouncement": "Oznámení uloženo.",
"setOmbiProfile": "Uložený ombi profil.",
"updateApplied": "Aktualizace byla použita, restartujte prosím.",
"updateAppliedRefresh": "Aktualizace byla použita, obnovte ji.",
"telegramVerified": "Účet telegramu ověřen.",
"accountConnected": "Účet připojen.",
"referralsEnabled": "Doporučení povolena.",
"errorSettingsAppliedNoHomescreenLayout": "Nastavení byla použita, ale použití rozvržení domovské obrazovky mohlo selhat.",
"errorHomescreenAppliedNoSettings": "Bylo použito rozvržení domovské obrazovky, ale použití nastavení mohlo selhat.",
"errorSettingsFailed": "Aplikace se nezdařila.",
"errorSaveEmail": "Uložení e-mailu se nezdařilo.",
"errorBlankFields": "Pole zůstala prázdná",
"errorDeleteProfile": "Smazání profilu {n} se nezdařilo",
"errorLoadProfiles": "Načtení profilů se nezdařilo.",
"errorCreateProfile": "Nepodařilo se vytvořit profil {n}",
"errorSetDefaultProfile": "Nepodařilo se nastavit výchozí profil.",
"errorLoadUsers": "Uživatele se nepodařilo načíst.",
"errorLoadSettings": "Nastavení se nepodařilo načíst.",
"errorSetOmbiProfile": "Uložení profilu ombi se nezdařilo.",
"errorLoadOmbiUsers": "Uživatele ombi se nepodařilo načíst.",
"errorChangedEmailAddress": "E-mailovou adresu uživatele {n} se nepodařilo změnit.",
"errorFailureCheckLogs": "Selhalo (zkontrolujte konzolu/protokoly)",
"errorPartialFailureCheckLogs": "Částečná chyba (zkontrolujte konzolu/protokoly)",
"errorUserCreated": "Nepodařilo se vytvořit uživatele {n}.",
"errorSendWelcomeEmail": "Nepodařilo se odeslat uvítací zprávu (zkontrolujte konzolu/protokoly)",
"errorApplyUpdate": "Aktualizaci se nepodařilo použít, zkuste to ručně.",
"errorCheckUpdate": "Kontrola aktualizace se nezdařila.",
"errorNoReferralTemplate": "Profil neobsahuje šablonu doporučení, přidejte si ji v nastavení.",
"updateAvailable": "Je k dispozici nová aktualizace, zkontrolujte nastavení.",
"noUpdatesAvailable": "Nejsou k dispozici žádné nové aktualizace."
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Upravit nastavení pro {n} uživatele",
"plural": "Upravit nastavení pro {n} uživatelů"
},
"enableReferralsFor": {
"singular": "Povolit doporučení pro {n} uživatele",
"plural": "Povolit doporučení pro {n} uživatelů"
},
"deleteNUsers": {
"singular": "Smazat {n} uživatele",
"plural": "Smazat {n} uživatelů"
},
"disableUsers": {
"singular": "Zakázat {n} uživatele",
"plural": "Zakázat {n} uživatelů"
},
"reEnableUsers": {
"singular": "Znovu povolte {n} uživatele",
"plural": "Znovu povolit {n} uživatelů"
},
"addUser": {
"singular": "Přidat uživatele",
"plural": "Přidat uživatele"
},
"deleteUser": {
"singular": "Smazat uživatele",
"plural": "Smazat uživatele"
},
"deletedUser": {
"singular": "Smazán {n} uživatel.",
"plural": "Smazaní {n} uživatelé."
},
"disabledUser": {
"singular": "Deaktivován {n} uživatel.",
"plural": "Zakázaných {n} uživatelů."
},
"enabledUser": {
"singular": "Povoleno {n} uživatele.",
"plural": "Povolených {n} uživatelů."
},
"announceTo": {
"singular": "Oznámeno {n} uživateli",
"plural": "Oznámit {n} uživatelům"
},
"appliedSettings": {
"singular": "Nastavení byla použita na {n} uživatele.",
"plural": "Nastavení byla použita na {n} uživatelů."
},
"extendExpiry": {
"singular": "Prodloužit platnost pro {n} uživatele",
"plural": "Prodloužit platnost pro {n} uživatelů"
},
"setExpiry": {
"singular": "Nastavit vypršení platnosti pro {n} uživatele",
"plural": "Nastavit vypršení platnosti pro {n} uživatelů"
},
"extendedExpiry": {
"singular": "Prodloužená platnost pro {n} uživatele.",
"plural": "Prodloužená platnost pro {n} uživatelů."
}
}
}

View File

@ -37,9 +37,9 @@
"profile": "Profil", "profile": "Profil",
"unknown": "Ukendt", "unknown": "Ukendt",
"label": "Etiket", "label": "Etiket",
"announce": "Meddelelse", "announce": "Annoncere",
"subject": "Emne", "subject": "Emne",
"message": "Besked", "message": "Meddelelse",
"variables": "Variabler", "variables": "Variabler",
"conditionals": "Betingelser", "conditionals": "Betingelser",
"preview": "Eksempel", "preview": "Eksempel",
@ -47,13 +47,13 @@
"donate": "Doner", "donate": "Doner",
"contactThrough": "Kontakt gennem:", "contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb", "extendExpiry": "Forlæng udløb",
"customizeMessages": "Tilpas Beskeder", "customizeMessages": "Tilpas Meddelelser",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.", "customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"markdownSupported": "Markdown understøttes.", "markdownSupported": "Markdown understøttes.",
"modifySettings": "Rediger indstillinger", "modifySettings": "Rediger indstillinger",
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.", "modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
"applyHomescreenLayout": "Anvend startskærmens layout", "applyHomescreenLayout": "Anvend startskærmens layout",
"sendDeleteNotificationEmail": "Send notifikations besked", "sendDeleteNotificationEmail": "Send notifikations meddelelse",
"sendDeleteNotifiationExample": "Din konto er blevet slettet.", "sendDeleteNotifiationExample": "Din konto er blevet slettet.",
"settingsRestart": "Genstart", "settingsRestart": "Genstart",
"settingsRestarting": "Genstarter…", "settingsRestarting": "Genstarter…",
@ -102,35 +102,7 @@
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.", "sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
"sendPWRValidFor": "Dette link er gyldigt i 30m.", "sendPWRValidFor": "Dette link er gyldigt i 30m.",
"accessJFA": "Få adgang til jfa-go", "accessJFA": "Få adgang til jfa-go",
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.", "accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
"after": "Efter",
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
"userPageLogin": "Brugerside: Login",
"buildTime": "Bygnings Tid",
"invite": "inviter",
"loginNotAdmin": "Ikke en Admin?",
"userLabel": "Brugeretiket",
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
"sortingBy": "Sortering Efter",
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
"clearSearch": "Ryd søgning",
"actions": "Handlinger",
"unlink": "Fjern linket til konto",
"enableReferrals": "Aktiver henvisninger",
"disableReferrals": "Deaktiver henvisninger",
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
"before": "Før",
"noResultsFound": "Ingen Resultater Fundet",
"settingsDependsOn": "{setting}: afhænger af {dependency}",
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
"filters": "Filtre",
"searchOptions": "Søge Indstillinger",
"matchText": "Match Tekst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Brugerside: Side",
"builtBy": "Bygget Af"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Ændret e-mail adresse på {n}.", "changedEmailAddress": "Ændret e-mail adresse på {n}.",
@ -161,16 +133,14 @@
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)", "errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)", "errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
"errorUserCreated": "Kunne ikke oprette bruger {n}.", "errorUserCreated": "Kunne ikke oprette bruger {n}.",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler", "errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.", "errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.", "errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.", "updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.", "noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
"savedAnnouncement": "Meddelelse gemt.", "savedAnnouncement": "Meddelelse gemt.",
"setOmbiProfile": "Gemt i ombi profilen.", "setOmbiProfile": "Gemt i ombi profilen.",
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.", "errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
"referralsEnabled": "Henvisninger aktiveret.",
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -210,8 +180,8 @@
"plural": "Aktiveret {n} brugere." "plural": "Aktiveret {n} brugere."
}, },
"announceTo": { "announceTo": {
"singular": "Send Meddelelse til {n} bruger", "singular": "Annoncer til {n} bruger",
"plural": "Send Meddelelse til {n} brugere" "plural": "Annoncer til {n} brugere"
}, },
"appliedSettings": { "appliedSettings": {
"singular": "Anvendte indstillinger til {n} bruger.", "singular": "Anvendte indstillinger til {n} bruger.",
@ -228,10 +198,6 @@
"setExpiry": { "setExpiry": {
"singular": "Indstil udløb for {n} bruger", "singular": "Indstil udløb for {n} bruger",
"plural": "Indstil udløb for {n} brugere" "plural": "Indstil udløb for {n} brugere"
},
"enableReferralsFor": {
"singular": "Aktiver Henvisninger for {n} bruger",
"plural": "Aktiver Henvisninger for {n} brugere"
} }
} }
} }

View File

@ -3,7 +3,7 @@
"name": "Deutsch (DE)" "name": "Deutsch (DE)"
}, },
"strings": { "strings": {
"invites": "Einladungen", "invites": "Invites",
"accounts": "Konten", "accounts": "Konten",
"settings": "Einstellungen", "settings": "Einstellungen",
"inviteDays": "Tage", "inviteDays": "Tage",
@ -94,7 +94,7 @@
"accessJFA": "jfa-go Zugriff", "accessJFA": "jfa-go Zugriff",
"sendPWRValidFor": "Der Link ist 30m gültig.", "sendPWRValidFor": "Der Link ist 30m gültig.",
"logs": "Logdaten", "logs": "Logdaten",
"setExpiry": "Ablaufdatum setzen", "setExpiry": "Ablauf setzen",
"sendPWRSuccess": "Link zur Passwortrücksetzung versandt.", "sendPWRSuccess": "Link zur Passwortrücksetzung versandt.",
"sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.", "sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.",
"sendPWR": "Sende Passwortrücksetzung", "sendPWR": "Sende Passwortrücksetzung",
@ -115,28 +115,7 @@
"after": "nach", "after": "nach",
"before": "vor", "before": "vor",
"unlink": "Account trennen", "unlink": "Account trennen",
"sortingBy": "Sortieren nach", "sortingBy": "Sortieren nach"
"activity": "Aktivität",
"settingsMaybeUnderAdvanced": "Tipp: Du könntest finden, wonach Du suchst, indem Du die erweiterten Einstellungen aktivierst.",
"enableReferralsProfileDescription": "Gib Benutzern, die mit diesem Profil erstellt wurden, einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Erstelle eine Einladung mit den gewünschten Einstellungen und wähle sie dann hier aus. Jede Empfehlung basiert dann auf dieser Einladung. Du kannst die Einladung nach Abschluss löschen.",
"removeExpiry": "Ablaufdatum entfernen",
"enterExpiry": "Ablaufdatum eingeben",
"keepSearchingDescription": "Die Suche umfasst nur bereits geladene Aktivitäten. Klicke unten um alle Aktivitäten zu durchsuchen.",
"useInviteExpiry": "Ablaufdatum des Profils/der Einladung setzen",
"useInviteExpiryNote": "Standardmässig laufen Einladungen nach 90 Tagen ab, können jedoch vom Benutzer erneuert werden. Aktiviere diese Option, damit die Empfehlung nach der festgelegten Zeit deaktiviert wird.",
"settingsHiddenDependency": "Zutreffende Einstellungen sind ausgeblendet, da sie vom Wert einer anderen Einstellung abhängen:",
"deleted": "Gelöscht",
"disabled": "Deaktiviert",
"keepSearching": "Weiter suchen",
"enableReferralsDescription": "Gib Benutzern einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Dieser kann aus einer Empfehlungsvorlage im Profil oder aus einer bestehenden Einladung stammen.",
"settingsDependsOn": "{setting}: abhängig von {dependency}",
"settingsAdvancedMode": "{setting}: Erweiterte Einstellungen müssen aktiviert sein",
"invite": "Einladung",
"userLabelDescription": "Label welches auf Benutzer angewendet wird, die mit dieser Einladung erstellt wurden.",
"enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.", "changedEmailAddress": "E-Mail-Adresse von {n} geändert.",

View File

@ -6,7 +6,6 @@
"invites": "Invites", "invites": "Invites",
"invite": "Invite", "invite": "Invite",
"accounts": "Accounts", "accounts": "Accounts",
"activity": "Activity",
"settings": "Settings", "settings": "Settings",
"inviteMonths": "Months", "inviteMonths": "Months",
"inviteDays": "Days", "inviteDays": "Days",
@ -22,6 +21,7 @@
"select": "Select", "select": "Select",
"name": "Name", "name": "Name",
"date": "Date", "date": "Date",
"setExpiry": "Set expiry",
"updates": "Updates", "updates": "Updates",
"update": "Update", "update": "Update",
"download": "Download", "download": "Download",
@ -54,17 +54,9 @@
"reset": "Reset", "reset": "Reset",
"donate": "Donate", "donate": "Donate",
"unlink": "Unlink Account", "unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:", "contactThrough": "Contact through:",
"extendExpiry": "Extend expiry", "extendExpiry": "Extend expiry",
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.", "sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.", "sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.", "sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@ -78,12 +70,7 @@
"disableReferrals": "Disable Referrals", "disableReferrals": "Disable Referrals",
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.", "enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.", "enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout", "applyHomescreenLayout": "Apply homescreen layout",
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
"applyOmbi": "Apply Ombi profile (if available)",
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message", "sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.", "sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart", "settingsRestart": "Restart",
@ -96,14 +83,8 @@
"settingsRefreshPage": "Refresh the page in a few seconds.", "settingsRefreshPage": "Refresh the page in a few seconds.",
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.", "settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
"settingsSave": "Save", "settingsSave": "Save",
"settingsHiddenDependency": "Matching settings are hidden because they depend on the value of another setting:",
"settingsDependsOn": "{setting}: Depends on {dependency}",
"settingsAdvancedMode": "{setting}: Advanced Settings must be enabled",
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
"ombiProfile": "Ombi user profile", "ombiProfile": "Ombi user profile",
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.", "ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
"jellyseerrProfile": "Jellyseerr user profile",
"jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
"userProfiles": "User Profiles", "userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.", "userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
"userProfilesIsDefault": "Default", "userProfilesIsDefault": "Default",
@ -132,7 +113,6 @@
"accessJFA": "Access jfa-go", "accessJFA": "Access jfa-go",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.", "accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
"sortingBy": "Sorting By", "sortingBy": "Sorting By",
"sortDirection": "Sort Direction",
"filters": "Filters", "filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.", "clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search", "clearSearch": "Clear search",
@ -142,71 +122,11 @@
"jellyfinID": "Jellyfin ID", "jellyfinID": "Jellyfin ID",
"userPageLogin": "User Page: Login", "userPageLogin": "User Page: Login",
"userPagePage": "User Page: Page", "userPagePage": "User Page: Page",
"postSignupCard": "Post-signup help card",
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"buildTime": "Build Time", "buildTime": "Build Time",
"builtBy": "Built By", "builtBy": "Built By",
"buildTags": "Build Tags", "loginNotAdmin": "Not an Admin?"
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}",
"accountUnlinked": "{contactMethod} removed: {user}",
"accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}",
"accountDeleted": "Account deleted: {user}",
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}.",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {invite}",
"inviteExpired": "Invite expired: {invite}",
"fromInvite": "From Invite",
"byAdmin": "By Admin",
"byUser": "By User",
"byJfaGo": "By jfa-go",
"activityID": "Activity ID",
"title": "Title",
"usersMentioned": "User mentioned",
"actor": "Actor",
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled",
"accountEnabledFilter": "Account Enabled",
"contactLinkedFilter": "Contact Linked",
"contactUnlinkedFilter": "Contact Unlinked",
"passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired",
"loadMore": "Load More",
"loadAll": "Load All",
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
"backupDownloadRestore": "Download / Restore",
"backupUpload": "Upload & Restore Backup",
"backupDownload": "Download Backup",
"backupRestore": "Restore Backup",
"backupNow": "Backup Now",
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page",
"wiki": "Wiki"
}, },
"notifications": { "notifications": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.", "userCreated": "User {n} created.",
"createProfile": "Created profile {n}.", "createProfile": "Created profile {n}.",
@ -215,15 +135,11 @@
"sentAnnouncement": "Announcement sent.", "sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.", "savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.", "setOmbiProfile": "Stored ombi profile.",
"savedProfile": "Stored profile changes.",
"updateApplied": "Update applied, please restart.", "updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.", "telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.", "accountConnected": "Account connected.",
"referralsEnabled": "Referrals enabled.", "referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorInviteNoLongerExists": "Invite no longer exists.",
"errorInviteNotFound": "Invite not found.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.", "errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.", "errorSettingsFailed": "Application failed.",
@ -232,7 +148,6 @@
"errorDeleteProfile": "Failed to delete profile {n}", "errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.", "errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}", "errorCreateProfile": "Failed to create profile {n}",
"errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.", "errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.", "errorLoadUsers": "Failed to load users.",
"errorLoadSettings": "Failed to load settings.", "errorLoadSettings": "Failed to load settings.",
@ -246,8 +161,6 @@
"errorApplyUpdate": "Failed to apply update, try manually.", "errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.", "errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.", "errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.", "updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available." "noUpdatesAvailable": "No new updates available."
}, },

View File

@ -116,27 +116,7 @@
"after": "Después", "after": "Después",
"before": "Antes", "before": "Antes",
"unlink": "Desvincular cuenta", "unlink": "Desvincular cuenta",
"clickToRemoveFilter": "Haga clic para eliminar el filtro.", "clickToRemoveFilter": "Haga clic para eliminar el filtro."
"removeExpiry": "Eliminar caducidad",
"enterExpiry": "Introduzca una caducidad",
"useInviteExpiry": "Establecer caducidad desde el perfil/invitación",
"noResultsFound": "Ningún resultado encontrado",
"settingsDependsOn": "{setting}: Depende de {dependency}",
"activity": "Actividad",
"disabled": "Desactivado",
"deleted": "Eliminado",
"keepSearching": "Seguir buscando",
"keepSearchingDescription": "Solo se ha buscado en las actividades cargadas actualmente. Clique a continuación si quiere buscar en todas las actividades.",
"settingsAdvancedMode": "{setting}: Los ajustes avanzados deben estar habilitados",
"settingsMaybeUnderAdvanced": "Consejo: Puede que encuentre lo que busca si habilita los Ajustes avanzados.",
"invite": "Invitar",
"userLabel": "Etiqueta de usuario",
"userLabelDescription": "Etiqueta que aplicar a usuarios creados con esta invitación.",
"enableReferrals": "Habilitar referencias",
"disableReferrals": "Deshabilitar referencias",
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.", "changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",

View File

@ -118,85 +118,7 @@
"userPagePage": "Page utilisateur : Page", "userPagePage": "Page utilisateur : Page",
"after": "Après", "after": "Après",
"before": "Avant", "before": "Avant",
"unlink": "Délier le compte", "unlink": "Délier le compte"
"enableReferrals": "Activer Parrainage",
"enableReferralsDescription": "Offrez aux utilisateurs un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Peut provenir modèle de profil ou dune invitation existante.",
"invite": "Inviter",
"userLabel": "Étiquette",
"userLabelDescription": "Étiquette à appliquer aux utilisateurs créés avec cette invitation.",
"disableReferrals": "Désactiver Parrainage",
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée.",
"loginNotAdmin": "Vous n'êtes pas administrateur ?",
"removeExpiry": "Supprimer l'expiration",
"enterExpiry": "Entrez une date d'expiration",
"useInviteExpiry": "Définir l'expiration du profil/invitation",
"sortDirection": "Ordre de trie",
"referrer": "Référence",
"accountLinked": "{contactMethod} lié : {user}",
"accountUnlinked": "{contactMethod} supprimé : {user}",
"accountResetPassword": "{user} réinitialise son mot de passe",
"expirationBasedOn": "Date donnée basée sur le 1er utilisateur.",
"accountDeleted": "Compte supprimé : {user}",
"accountChangedPassword": "{user} a changé son mot de passe",
"accountCreated": "Compte créé : {user}",
"accountDisabled": "Compte désactivé : {user}",
"accountReEnabled": "Compte réactivé : {user}",
"accountExpired": "Compte expiré : {user}",
"accountWillExpire": "Le compte expirera le {date}.",
"backups": "Sauvegardes",
"backupsDescription": "Des sauvegardes de la base de données peuvent être effectuées, restaurées ou téléchargées à partir d'ici.",
"backupsCopy": "Lors de l'application d'une sauvegarde, une copie du dossier \"db\" d'origine sera créée à côté, en cas de problème.",
"backupDownloadRestore": "Télécharger/Restaurer",
"backupUpload": "Télécharger et restaurer la sauvegarde",
"backupDownload": "Télécharger la sauvegarde",
"backupRestore": "Restaurer la sauvegarde",
"backupNow": "Sauvegarder maintenant",
"backupCreated": "Sauvegarde créée",
"backupCanDownload": "Vous pouvez également cliquer ci-dessous pour télécharger la sauvegarde.",
"wikiPage": "Wiki page",
"activity": "Activité",
"deleted": "Supprimé",
"disabled": "Désactivé",
"keepSearching": "Continuer la recherche",
"keepSearchingDescription": "Seules les activités actuellement chargées ont été recherchées. Cliquez ci-dessous si vous souhaitez rechercher toutes les activités.",
"settingsHiddenDependency": "Les paramètres correspondants sont masqués car ils dépendent de la valeur d'un autre paramètre :",
"settingsDependsOn": "{setting} : dépend de {dependency}",
"settingsMaybeUnderAdvanced": "Astuce : Vous trouverez peut-être ce que vous cherchez en activant les paramètres avancés.",
"settingsAdvancedMode": "{setting} : les paramètres avancés doivent être activés",
"actorDescription": "La chose qui a provoqué cette action. \"user\"/\"admin\"/\"service\" ou un nom d'utilisateur.",
"activityID": "ID d'activité",
"byUser": "Par Utilisateur",
"inviteExpired": "Invitation expirée : {invite}",
"byJfaGo": "Par jfa-go",
"accountDisabledFilter": "Compte désactivé",
"inviteCreated": "Invitation créée : {invite}",
"inviteDeleted": "Invitation supprimée : {invite}",
"fromInvite": "À partir de l'invitation",
"accountDeletionFilter": "Suppression de compte",
"userDeleted": "L'utilisateur a été supprimé.",
"userDisabled": "L'utilisateur a été désactivé",
"accountCreationFilter": "Création de compte",
"title": "Titre",
"usersMentioned": "Utilisateur mentionné",
"actor": "Acteur",
"byAdmin": "Par Administrateur",
"passwordResetFilter": "Réinitialisation du mot de passe",
"loadMore": "Charger plus",
"accountEnabledFilter": "Compte activé",
"inviteCreatedFilter": "Invitation crée",
"inviteDeletedFilter": "Invitation supprimée/expirée",
"noMoreResults": "Plus de résultats.",
"totalRecords": "{n} Nombre total d'enregistrements",
"passwordChangeFilter": "Mot de passe changé",
"loadedRecords": "{n} Chargé",
"shownRecords": "{n} affiché",
"contactUnlinkedFilter": "Contact sans lien",
"contactLinkedFilter": "Contact lié",
"loadAll": "Tout charger",
"noResultsFound": "Aucun résultat trouvé",
"useInviteExpiryNote": "Par défaut, les invitations expirent après 90 jours mais peuvent être renouvelées par l'utilisateur. Activez la désactivation de la référence après le délai défini.",
"backupsFormatNote": "Seuls les fichiers de sauvegarde au format standard seront affichés ici. Pour en utiliser un autre, veuillez charger la sauvegarde manuellement.",
"backupCanBeFound": "La sauvegarde peut être trouvée sur le serveur à {filepath}."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.", "changedEmailAddress": "Adresse e-mail modifiée de {n}.",
@ -234,15 +156,7 @@
"accountConnected": "Compte connecté.", "accountConnected": "Compte connecté.",
"savedAnnouncement": "Annonce enregistrée.", "savedAnnouncement": "Annonce enregistrée.",
"setOmbiProfile": "Profil ombi enregistré.", "setOmbiProfile": "Profil ombi enregistré.",
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi.", "errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi."
"errorNoReferralTemplate": "Le profil ne contient pas de modèle de référence, ajoutez-en un dans les paramètres.",
"referralsEnabled": "Parrainage activer.",
"errorLoadActivities": "Échec du chargement des activités.",
"pathCopied": "Chemin complet copié dans le presse-papiers.",
"activityDeleted": "Activité supprimée.",
"errorInviteNoLongerExists": "L'invitation n'existe plus.",
"errorInviteNotFound": "Invitation introuvable.",
"errorInvalidDate": "La date n'est pas valide."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -300,10 +214,6 @@
"setExpiry": { "setExpiry": {
"singular": "Définir l'expiration pour {n} utilisateur", "singular": "Définir l'expiration pour {n} utilisateur",
"plural": "Définir l'expiration pour {n} utilisateurs" "plural": "Définir l'expiration pour {n} utilisateurs"
},
"enableReferralsFor": {
"singular": "Activer les parrainages pour {n} utilisateur",
"plural": "Activer les parrainages pour {n} utilisateur"
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"meta": { "meta": {
"name": "Magyar (HU)" "name": "Angol (US)"
}, },
"strings": { "strings": {
"invites": "Meghívások", "invites": "Meghívások",

View File

@ -74,9 +74,7 @@
"select": "Pilih", "select": "Pilih",
"search": "Cari", "search": "Cari",
"download": "Unduh", "download": "Unduh",
"inviteMonths": "Bulan", "inviteMonths": "Bulan"
"inviteDuration": "Durasi undangan",
"activity": "Aktivitas"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Alamat email {n} diubah.", "changedEmailAddress": "Alamat email {n} diubah.",

View File

@ -102,100 +102,7 @@
"ombiProfile": "Ombi gebruikersprofiel", "ombiProfile": "Ombi gebruikersprofiel",
"logs": "Logs", "logs": "Logs",
"accessJFA": "Toegang tot jfa-go", "accessJFA": "Toegang tot jfa-go",
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.", "accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
"noResultsFound": "Geen resultaten gevonden",
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
"builtBy": "Build door",
"buildTime": "Build moment",
"userPageLogin": "Gebruikerspagina: Inloggen",
"loginNotAdmin": "Geen beheerder?",
"before": "Voor",
"unlink": "Ontkoppel account",
"after": "Na",
"invite": "Uitnodiging",
"userLabel": "Gebruikerslabel",
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
"enableReferrals": "Verwijzingen inschakelen",
"disableReferrals": "Verwijzingen uitschakelen",
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
"settingsDependsOn": "{setting}: hangt af van {dependency}",
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
"sortingBy": "Sorteren naar",
"filters": "Filters",
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
"clearSearch": "Zoekopdracht verwijderen",
"actions": "Acties",
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina",
"activity": "Activiteit",
"deleted": "Verwijderd",
"disabled": "Uitgeschakeld",
"keepSearching": "Blijf zoeken",
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
"sortDirection": "Sorteerrichting",
"referrer": "Verwijzer",
"accountLinked": "{contactMethod} gekoppeld: {user}",
"accountUnlinked": "{contactMethod} verwijderd: {user}",
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
"accountDisabled": "Account uitgeschakeld: {user}",
"accountDeleted": "Account verwijderd: {user}",
"accountCreated": "Account aangemaakt: {user}",
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
"accountExpired": "Account verlopen: {user}",
"userDeleted": "Gebruiker is verwijderd.",
"userDisabled": "Gebruiker is uitgeschakeld",
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
"inviteExpired": "Uitnodiging verlopen: {invite}",
"fromInvite": "Via uitnodiging",
"byAdmin": "Door beheerder",
"byUser": "Door gebruiker",
"byJfaGo": "Door jfa-go",
"activityID": "Activiteit ID",
"title": "Titel",
"usersMentioned": "Genoemde gebruiker",
"actor": "Uitvoerder",
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
"accountCreationFilter": "Aanmaken van account",
"accountDeletionFilter": "Verwijderen van account",
"accountDisabledFilter": "Account uitgeschakeld",
"accountEnabledFilter": "Account ingeschakeld",
"contactLinkedFilter": "Contact gekoppeld",
"contactUnlinkedFilter": "Contact ontkoppeld",
"passwordChangeFilter": "Wachtwoord gewijzigd",
"passwordResetFilter": "Wachtwoord gereset",
"inviteCreatedFilter": "Uitnodiging aangemaakt",
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
"loadMore": "Laad meer",
"loadAll": "Laad alles",
"noMoreResults": "Niet meer resultaten.",
"totalRecords": "{n} documenten totaal",
"loadedRecords": "{n} geladen",
"shownRecords": "{n} getoond",
"useInviteExpiry": "Neem verloop over van profiel/uitnodiging",
"backups": "Backups",
"removeExpiry": "Verwijder verloop",
"enterExpiry": "Voer verloop in",
"accountWillExpire": "Account verloopt op {date}.",
"expirationBasedOn": "Datum gebaseerd op 1e gebruiker.",
"backupsFormatNote": "Alleen backupbestanden met het standaard naamformaat worden hier getoond. Upload handmatig om een ander bestand te gebruiken.",
"backupDownloadRestore": "Downloaden / Terugzetten",
"backupUpload": "Upload backup & zet terug",
"backupDownload": "Download backup",
"backupRestore": "Backup terugzetten",
"backupNow": "Nu backup maken",
"backupCreated": "Backup gemaakt",
"backupCanBeFound": "De backup kan op de server gevonden worden onder {filepath}.",
"backupCanDownload": "Of klik hieronder om de backup te downloaden.",
"wikiPage": "Wiki pagina",
"useInviteExpiryNote": "Standaard verlopen uitnodigingen na 90 dagen, maar kunnen ze vernieuwd worden door de gebruiker. Schakel in om de verwijzing uit te schakelen na de ingestelde tijd.",
"backupsDescription": "Hier kunnen backups van de database gemaakt, teruggezet, of gedownload worden.",
"backupsCopy": "Bij het toepassen van een backup wordt er een kopie van de originele \"db\" folder naast gemaakt, voor het geval er iets misgaat."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.", "changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@ -233,15 +140,7 @@
"accountConnected": "Account gekoppeld.", "accountConnected": "Account gekoppeld.",
"savedAnnouncement": "Aankondiging opgeslagen.", "savedAnnouncement": "Aankondiging opgeslagen.",
"setOmbiProfile": "Opgeslagen ombi-profiel.", "setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.", "errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief.",
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt.",
"pathCopied": "Volledig pad gekopieerd naar klembord.",
"errorInvalidDate": "Ongeldige datum."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -299,10 +198,6 @@
"setExpiry": { "setExpiry": {
"singular": "Stel verloop in voor {n} gebruiker", "singular": "Stel verloop in voor {n} gebruiker",
"plural": "Stel verloop in voor {n} gebruikers" "plural": "Stel verloop in voor {n} gebruikers"
},
"enableReferralsFor": {
"plural": "Verwijzingen activeren voor {1} gebruikers",
"singular": "Verwijzingen activeren voor {1} gebruiker"
} }
} }
} }

View File

@ -101,8 +101,7 @@
"deleteTemplate": "Usuń szablon", "deleteTemplate": "Usuń szablon",
"templateEnterName": "Wprowadź nazwę aby zapisać szablon.", "templateEnterName": "Wprowadź nazwę aby zapisać szablon.",
"accessJFA": "", "accessJFA": "",
"accessJFASettings": "", "accessJFASettings": ""
"invite": "Zaproś"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Zmieniono adres email {n}.", "changedEmailAddress": "Zmieniono adres email {n}.",

View File

@ -83,7 +83,7 @@
"conditionals": "Condicionais", "conditionals": "Condicionais",
"donate": "Doar", "donate": "Doar",
"contactThrough": "Contato através:", "contactThrough": "Contato através:",
"sendPIN": "Peça ao usuário para enviar o PIN abaixo para o bot.", "sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.",
"searchDiscordUser": "Digite o nome de usuário do Discord.", "searchDiscordUser": "Digite o nome de usuário do Discord.",
"findDiscordUser": "Encontrar usuário Discord", "findDiscordUser": "Encontrar usuário Discord",
"linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.", "linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.",
@ -102,100 +102,7 @@
"sendPWRSuccess": "Link de redefinição de senha enviado.", "sendPWRSuccess": "Link de redefinição de senha enviado.",
"sendPWRSuccessManual": "Se o usuário não o recebeu, pressione copiar para obter um link para enviar manualmente a ele.", "sendPWRSuccessManual": "Se o usuário não o recebeu, pressione copiar para obter um link para enviar manualmente a ele.",
"sendPWRValidFor": "O link é válido por 30m.", "sendPWRValidFor": "O link é válido por 30m.",
"accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral.", "accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral."
"after": "Depois",
"removeExpiry": "Remover expiração",
"enableReferrals": "Habilitar referências",
"disableReferrals": "Desativar referências",
"invite": "Convite",
"before": "Antes",
"unlink": "Desvincular conta",
"enterExpiry": "Insira um vencimento",
"useInviteExpiry": "Definir expiração do perfil/convite",
"useInviteExpiryNote": "Por padrão, os convites expiram após 90 dias, mas podem ser renovados pelo usuário. Habilite para que o encaminhamento seja desabilitado após o tempo definido.",
"noResultsFound": "Nenhum resultado encontrado",
"activity": "Atividade",
"userLabel": "Rótulo de usuário",
"userLabelDescription": "Rótulo a ser aplicado aos usuários criados com este convite.",
"deleted": "Excluído",
"disabled": "Desabilitado",
"keepSearching": "Continue procurando",
"keepSearchingDescription": "Apenas as atividades atualmente carregadas foram pesquisadas. Clique abaixo se desejar pesquisar todas as atividades.",
"enableReferralsDescription": "Forneça aos usuários um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Pode ser proveniente de um modelo de indicação em um perfil ou de um convite existente.",
"enableReferralsProfileDescription": "Forneça aos usuários criados com este perfil um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Crie um convite com as configurações desejadas e selecione-o aqui. Cada indicação será então baseada neste convite. Você pode excluir o convite depois de concluído.",
"fromInvite": "Do convite",
"inviteDeletedFilter": "Convite excluído/expirado",
"accountDisabled": "Conta desativada: {user}",
"backupsDescription": "Cópias de segurança do banco de dados podem ser feitos, restaurados ou baixados aqui.",
"backupsFormatNote": "Somente arquivos de Cópias de segurança com formato de nome padrão serão mostrados aqui. Para usar qualquer outro, carregue o backup manualmente.",
"backupCanDownload": "Como alternativa, clique abaixo para baixar Cópia de segurança.",
"inviteCreated": "Convite criado: {invite}",
"buildTime": "Hora de construir",
"inviteDeleted": "Convite excluído: {invite}",
"inviteExpired": "O convite expirou: {invite}",
"byAdmin": "Por administrador",
"byUser": "Por usuário",
"byJfaGo": "Por jfa-go",
"actor": "Ator",
"actorDescription": "O que causou essa ação. \"user\"/\"admin\"/\"daemon\" ou um nome de usuário.",
"accountCreationFilter": "Criação de conta",
"accountDeletionFilter": "Exclusão de conta",
"accountDisabledFilter": "conta desativada",
"accountEnabledFilter": "Conta ativada",
"contactLinkedFilter": "Contato Linkedin",
"contactUnlinkedFilter": "Contato não vinculado",
"passwordResetFilter": "Redefinição de senha",
"inviteCreatedFilter": "Convite criado",
"loginNotAdmin": "Você não é administrador?",
"loadedRecords": "{n} Carregado",
"shownRecords": "{n} Exibido",
"searchOptions": "Opções de busca",
"matchText": "Corresponder Texto",
"jellyfinID": "ID do Jellyfin",
"sortingBy": "Classificando por",
"sortDirection": "Classificar direção",
"settingsHiddenDependency": "As configurações correspondentes ficam ocultas porque dependem do valor de outra configuração:",
"settingsDependsOn": "{setting}: depende de {dependency}",
"settingsAdvancedMode": "{setting}: as configurações avançadas devem estar habilitadas",
"settingsMaybeUnderAdvanced": "Dica: você pode encontrar o que procura ativando Configurações avançadas.",
"accountResetPassword": "{user} redefiniu a senha",
"accountCreated": "Conta criada: {user}",
"accountDeleted": "Conta excluída: {user}",
"accountExpired": "A conta expirou: {user}",
"accountWillExpire": "A conta expirará em {data}.",
"expirationBasedOn": "Data fornecida com base no primeiro usuário.",
"userDeleted": "O usuário foi excluído.",
"userDisabled": "O usuário foi desativado",
"activityID": "ID da atividade",
"accountChangedPassword": "{user} alterou a senha",
"title": "Título",
"accountReEnabled": "Conta reativada: {user}",
"referrer": "Indicador",
"usersMentioned": "Usuário mencionado",
"passwordChangeFilter": "Senha alterada",
"loadAll": "Carregar tudo",
"loadMore": "Carregar mais",
"noMoreResults": "Não há mais resultados.",
"totalRecords": "{n} Total de registros",
"filters": "Filtros",
"clickToRemoveFilter": "Clique para remover este filtro.",
"clearSearch": "Limpar pesquisa",
"actions": "Ações",
"userPageLogin": "Página do usuário: Entrar",
"userPagePage": "Página do usuário: página",
"builtBy": "Criado por",
"backups": "Cópias de segurança",
"backupDownloadRestore": "Baixar / Restaurar",
"backupsCopy": "Ao aplicar uma Cópias de segurança, será feita uma cópia da pasta \"db\" original ao lado dele, caso algo dê errado.",
"backupUpload": "Carregar e restaurar Cópias de segurança",
"backupDownload": "Baixar cópia de segurança",
"backupRestore": "Restaurar cópia de segurança",
"backupNow": "Faça cópia de segurança agora",
"backupCreated": "Cópia de segurança criada",
"backupCanBeFound": "A Cópia de segurança pode ser encontrado no servidor em {filepath}.",
"wikiPage": "Página Wiki",
"accountLinked": "{contactMethod} vinculado: {user}",
"accountUnlinked": "{contactMethod} removido: {user}"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Endereço de e-mail alterado de {n}.", "changedEmailAddress": "Endereço de e-mail alterado de {n}.",
@ -233,15 +140,7 @@
"accountConnected": "Conta conectada.", "accountConnected": "Conta conectada.",
"savedAnnouncement": "Anúncio salvo.", "savedAnnouncement": "Anúncio salvo.",
"setOmbiProfile": "Perfil ombi armazenado.", "setOmbiProfile": "Perfil ombi armazenado.",
"errorSetOmbiProfile": "Falha ao armazenar o perfil ombi.", "errorSetOmbiProfile": "Falha ao armazenar o perfil ombi."
"errorNoReferralTemplate": "O perfil não contém modelo de referência. Adicione um nas configurações.",
"pathCopied": "Caminho completo copiado para a área de transferência.",
"referralsEnabled": "Referências habilitadas.",
"activityDeleted": "Atividade excluída.",
"errorInviteNoLongerExists": "O convite não existe mais.",
"errorInviteNotFound": "Convite não encontrado.",
"errorLoadActivities": "Falha ao carregar atividades.",
"errorInvalidDate": "A data é inválida."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -299,10 +198,6 @@
"setExpiry": { "setExpiry": {
"singular": "Definir expiração para {a} usuário", "singular": "Definir expiração para {a} usuário",
"plural": "Definir expiração para {a} usuários" "plural": "Definir expiração para {a} usuários"
},
"enableReferralsFor": {
"plural": "Ativar referências para {n} usuários",
"singular": "Ativar referências para {n} usuário"
} }
} }
} }

View File

@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值", "ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。", "ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案", "userProfiles": "用户档案",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。", "userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认", "userProfilesIsDefault": "默认",
"userProfilesLibraries": "库", "userProfilesLibraries": "库",
"addProfile": "添加档案", "addProfile": "添加档案",
@ -117,85 +117,7 @@
"before": "之前", "before": "之前",
"unlink": "取消关联帐户", "unlink": "取消关联帐户",
"sortingBy": "排序方式", "sortingBy": "排序方式",
"userPageLogin": "用户页面:登录", "userPageLogin": "用户页面:登录"
"activity": "活动",
"userLabelDescription": "标签应用于使用此邀请创建的用户。",
"disabled": "禁用",
"keepSearchingDescription": "只有当前加载的活动被搜索了。如果您想搜索所有活动,请点击下方。",
"enableReferralsDescription": "为用户提供一个个人的推荐链接,类似于邀请,以便他们发送给朋友和家人。可以从个人资料中的推荐模板获取,或从现有的邀请中获取。",
"userDeleted": "用户已被删除。",
"inviteCreated": "邀请已创建:{invite}",
"usersMentioned": "用户提到的",
"actorDescription": "引起这个操作的事物。可以是“用户”、“管理员”、“守护程序”或用户名。",
"loginNotAdmin": "不是管理员?",
"invite": "邀请",
"noResultsFound": "没有发现任何结果",
"settingsHiddenDependency": "匹配设置被隐藏,因为它们取决于另一个设置的值:",
"settingsDependsOn": "{setting}:依赖于 {dependency}",
"settingsAdvancedMode": "{setting}:必须启用高级设置",
"settingsMaybeUnderAdvanced": "提示:通过启用高级设置,您可能会找到您正在寻找的内容。",
"userLabel": "用户标签",
"deleted": "删除",
"keepSearching": "继续搜索",
"enableReferrals": "启用推荐",
"disableReferrals": "禁用推荐",
"enableReferralsProfileDescription": "为使用该个人资料创建的用户提供一个类似邀请的个人推荐链接,以便他们发送给朋友和家人。创建一个具有所需设置的邀请,然后在此处进行选择。然后,每个推荐都将基于这个邀请。完成后,您可以删除邀请。",
"sortDirection": "排序方向",
"referrer": "推荐人",
"accountLinked": "{contactMethod} 已关联:{user}",
"accountUnlinked": "{contactMethod} 已移除:{user}",
"accountResetPassword": "{user} 重置了他们的密码",
"accountChangedPassword": "{user} 更改了他们的密码",
"accountCreated": "账户已创建:{user}",
"accountDeleted": "账户已删除:{user}",
"accountDisabled": "账户已禁用:{user}",
"accountReEnabled": "账户已重新启用:{user}",
"accountExpired": "账户已过期:{user}",
"userDisabled": "用户已被禁用",
"inviteDeleted": "邀请已删除:{invite}",
"inviteExpired": "邀请已过期:{invite}",
"fromInvite": "来自邀请",
"byAdmin": "由管理员发起的",
"byUser": "由用户发起的",
"byJfaGo": "由jfa-go发起的",
"activityID": "活动ID",
"title": "标题",
"actor": "角色",
"accountCreationFilter": "账户创建",
"accountDeletionFilter": "账户删除",
"accountDisabledFilter": "账户禁用",
"accountEnabledFilter": "账户启用",
"contactLinkedFilter": "联系方式已关联",
"contactUnlinkedFilter": "联系方式未关联",
"passwordChangeFilter": "密码已更改",
"passwordResetFilter": "密码重置",
"inviteCreatedFilter": "邀请已创建",
"inviteDeletedFilter": "邀请已删除/过期",
"loadMore": "加载更多",
"loadAll": "加载全部",
"noMoreResults": "没有更多结果了。",
"totalRecords": "{n} 总记录数",
"loadedRecords": "已加载{n}",
"shownRecords": "已显示{n}",
"removeExpiry": "用户过期删除时间",
"useInviteExpiryNote": "在默认情况下邀请会在90天之后过期但是用户可以手动续期该邀请。启动该设置手动设置有效期后会关闭推荐设置。",
"accountWillExpire": "账户将在{date}后过期。",
"expirationBasedOn": "根据第一个用户给出的日期。",
"backupsFormatNote": "此处仅显示具有标准名称格式的备份文件。如要使用其他名称的备份,请手动上传。",
"backupCanDownload": "或者,单击下面的按钮下载备份。",
"enterExpiry": "输入自定义过期时间",
"useInviteExpiry": "设置个人资料/邀请的有效期",
"backups": "备份设置",
"backupsDescription": "可以从这里制作、恢复或下载数据库的备份。",
"backupsCopy": "当在使用备份文件恢复时程序将创建原始“db”文件夹的副本以防出现问题。",
"backupDownloadRestore": "下载/恢复",
"backupUpload": "上传和恢复备份",
"backupRestore": "恢复备份",
"backupDownload": "下载备份",
"backupNow": "立即备份",
"backupCreated": "备份已创建",
"backupCanBeFound": "该备份可以在服务器上的 {filepath} 处找到。",
"wikiPage": "帮助文档"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "更改了 {n} 的电子邮件地址。", "changedEmailAddress": "更改了 {n} 的电子邮件地址。",
@ -233,15 +155,7 @@
"updateAvailable": "有新更新可用,请检查设置。", "updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。", "noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。", "setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。", "errorSetOmbiProfile": "无法保存ombi配置文件。"
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。",
"pathCopied": "完整路径已复制到剪贴板。",
"errorInvalidDate": "日期无效。"
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -299,10 +213,6 @@
"setExpiry": { "setExpiry": {
"plural": "为{n}用户设置到期时间", "plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间" "singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
} }
} }
} }

View File

@ -1,67 +0,0 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"username": "Uživatelské jméno",
"password": "Heslo",
"emailAddress": "Emailová adresa",
"name": "Název",
"submit": "Odeslat",
"send": "Poslat",
"success": "Hotovo",
"continue": "Pokračovat",
"error": "Chyba",
"copy": "Kopírovat",
"copied": "Zkopírováno",
"time24h": "Čas 24 hodin",
"time12h": "Čas 12 hodin",
"linkTelegram": "Link Telegram",
"contactEmail": "Kontakt přes Email",
"contactTelegram": "Kontakt přes Telegram",
"linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Kontakt přes Discord",
"theme": "Téma",
"refresh": "Obnovit",
"required": "Požadované",
"login": "Přihlásit se",
"logout": "Odhlásit se",
"admin": "Admin",
"enabled": "Povoleno",
"disabled": "Zakázáno",
"reEnable": "Znovu povolit",
"disable": "Zakázat",
"contactMethods": "Kontaktní metody",
"accountStatus": "Stav účtu",
"notSet": "Nenastaveno",
"expiry": "Uplynutí",
"add": "Přidat",
"edit": "Upravit",
"delete": "Vymazat",
"myAccount": "Můj účet",
"referrals": "Doporučení",
"inviteRemainingUses": "Zbývající použití"
},
"notifications": {
"errorLoginBlank": "Uživatelské jméno a/nebo heslo zůstalo prázdné.",
"errorConnection": "Nelze se připojit k jfa-go.",
"errorUnknown": "Neznámá chyba.",
"error401Unauthorized": "Neoprávněný. Zkuste stránku obnovit.",
"errorSaveSettings": "Nastavení se nepodařilo uložit."
},
"quantityStrings": {
"year": {
"singular": "{n} rok",
"plural": "{n} let"
},
"month": {
"singular": "{n} měsíc",
"plural": "{n} měsíců"
},
"day": {
"singular": "{n} den",
"plural": "{n} dní"
}
}
}

View File

@ -5,7 +5,7 @@
"strings": { "strings": {
"username": "Brugernavn", "username": "Brugernavn",
"password": "Adgangskode", "password": "Adgangskode",
"emailAddress": "Email adresse", "emailAddress": "E-mail Adresse",
"name": "Navn", "name": "Navn",
"submit": "Indsend", "submit": "Indsend",
"send": "Send", "send": "Send",
@ -36,12 +36,7 @@
"add": "Tilføj", "add": "Tilføj",
"edit": "Rediger", "edit": "Rediger",
"delete": "Slet", "delete": "Slet",
"inviteRemainingUses": "Resterende anvendelser", "inviteRemainingUses": "Resterende anvendelser"
"referrals": "Henvisninger",
"contactMethods": "Kontakt Metoder",
"accountStatus": "Kontostatus",
"notSet": "Ikke sat",
"myAccount": "Min Konto"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.", "errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
@ -50,18 +45,5 @@
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.", "error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne." "errorSaveSettings": "Kunne ikke gemme indstillingerne."
}, },
"quantityStrings": { "quantityStrings": {}
"year": {
"singular": "{n} År",
"plural": "{n} År"
},
"month": {
"singular": "{n} Månede",
"plural": "{n} Måneder"
},
"day": {
"singular": "{n} Dag",
"plural": "{n} Dage"
}
}
} }

View File

@ -48,8 +48,7 @@
"errorConnection": "Couldn't connect to jfa-go.", "errorConnection": "Couldn't connect to jfa-go.",
"errorUnknown": "Unknown error.", "errorUnknown": "Unknown error.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.", "error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSaveSettings": "Couldn't save settings.", "errorSaveSettings": "Couldn't save settings."
"errorSpecialSymbols": "Field cannot contain special symbols."
}, },
"quantityStrings": { "quantityStrings": {
"year": { "year": {

View File

@ -36,12 +36,7 @@
"add": "Agregar", "add": "Agregar",
"edit": "Editar", "edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
"inviteRemainingUses": "Usos restantes", "inviteRemainingUses": "Usos restantes"
"contactMethods": "Métodos de contacto",
"accountStatus": "Estado de la cuenta",
"notSet": "No establecido",
"myAccount": "Mi cuenta",
"referrals": "Referencias"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.", "errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
@ -50,18 +45,5 @@
"error401Unauthorized": "No autorizado. Intente actualizar la página.", "error401Unauthorized": "No autorizado. Intente actualizar la página.",
"errorSaveSettings": "No se pudo guardar la configuración." "errorSaveSettings": "No se pudo guardar la configuración."
}, },
"quantityStrings": { "quantityStrings": {}
"year": {
"plural": "{n} años",
"singular": "{n} año"
},
"month": {
"singular": "{n} mes",
"plural": "{n} meses"
},
"day": {
"singular": "{n} día",
"plural": "{n} días"
}
}
} }

View File

@ -5,7 +5,7 @@
"strings": { "strings": {
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"password": "Mot de passe", "password": "Mot de passe",
"emailAddress": "Adresse mail", "emailAddress": "Adresse courriel",
"name": "Nom", "name": "Nom",
"submit": "Soumettre", "submit": "Soumettre",
"send": "Envoyer", "send": "Envoyer",
@ -31,7 +31,7 @@
"enabled": "Activé", "enabled": "Activé",
"disabled": "Désactivé", "disabled": "Désactivé",
"reEnable": "Ré-activé", "reEnable": "Ré-activé",
"disable": "Désactiver", "disable": "Désactivé",
"expiry": "Expiration", "expiry": "Expiration",
"add": "Ajouter", "add": "Ajouter",
"edit": "Éditer", "edit": "Éditer",
@ -40,8 +40,7 @@
"accountStatus": "Statut du compte", "accountStatus": "Statut du compte",
"notSet": "Non défini", "notSet": "Non défini",
"myAccount": "Mon compte", "myAccount": "Mon compte",
"contactMethods": "Moyens de contact", "contactMethods": "Moyens de contact"
"referrals": "Programme de parrainage"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.", "errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
@ -52,16 +51,16 @@
}, },
"quantityStrings": { "quantityStrings": {
"year": { "year": {
"plural": "{n} années", "plural": "{n] années",
"singular": "{n} année" "singular": "{n] année"
}, },
"day": { "day": {
"singular": "{n} jour", "singular": "{n] jour",
"plural": "{n} jours" "plural": "{n] jours"
}, },
"month": { "month": {
"singular": "{n} mois", "singular": "{n] mois",
"plural": "{n} mois" "plural": "{n] mois"
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"meta": { "meta": {
"name": "Magyar (HU)" "name": "Angol (US)"
}, },
"strings": { "strings": {
"login": "Belépés", "login": "Belépés",

View File

@ -36,12 +36,7 @@
"add": "Voeg toe", "add": "Voeg toe",
"edit": "Bewerken", "edit": "Bewerken",
"delete": "Verwijderen", "delete": "Verwijderen",
"inviteRemainingUses": "Resterend aantal keer te gebruiken", "inviteRemainingUses": "Resterend aantal keer te gebruiken"
"referrals": "Verwijzingen",
"contactMethods": "Contactmethodes",
"accountStatus": "Account status",
"notSet": "Niet ingesteld",
"myAccount": "Mijn account"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.", "errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
@ -50,18 +45,5 @@
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.", "error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSaveSettings": "Opslaan van instellingen mislukt." "errorSaveSettings": "Opslaan van instellingen mislukt."
}, },
"quantityStrings": { "quantityStrings": {}
"year": {
"singular": "{n} jaar",
"plural": "{n} jaar"
},
"month": {
"singular": "{n} maand",
"plural": "{n} maanden"
},
"day": {
"singular": "{n} dag",
"plural": "{n} dagen"
}
}
} }

View File

@ -7,7 +7,7 @@
"password": "Senha", "password": "Senha",
"emailAddress": "Endereço de e-mail", "emailAddress": "Endereço de e-mail",
"name": "Nome", "name": "Nome",
"submit": "Envie", "submit": "Enviar",
"send": "Enviar", "send": "Enviar",
"success": "Sucesso", "success": "Sucesso",
"continue": "Continuar", "continue": "Continuar",
@ -36,12 +36,7 @@
"add": "Adicionar", "add": "Adicionar",
"edit": "Editar", "edit": "Editar",
"delete": "Deletar", "delete": "Deletar",
"inviteRemainingUses": "Uso restantes", "inviteRemainingUses": "Uso restantes"
"referrals": "Referências",
"contactMethods": "Métodos de contato",
"accountStatus": "Estado da conta",
"notSet": "Não configurado",
"myAccount": "Minha conta"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.", "errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
@ -50,18 +45,5 @@
"error401Unauthorized": "Não autorizado. Tente atualizar a página.", "error401Unauthorized": "Não autorizado. Tente atualizar a página.",
"errorSaveSettings": "Não foi possível salvar as configurações." "errorSaveSettings": "Não foi possível salvar as configurações."
}, },
"quantityStrings": { "quantityStrings": {}
"day": {
"plural": "{n} Dias",
"singular": "{n} Dia"
},
"year": {
"singular": "{n} Ano",
"plural": "{n} anos"
},
"month": {
"singular": "{n} Mês",
"plural": "{n} meses"
}
}
} }

View File

@ -23,22 +23,7 @@
"expiry": "Löper ut", "expiry": "Löper ut",
"edit": "Redigera", "edit": "Redigera",
"delete": "Radera", "delete": "Radera",
"inviteRemainingUses": "Återstående användningar", "inviteRemainingUses": "Återstående användningar"
"send": "Skicka",
"linkDiscord": "Länka Discord",
"copied": "Kopierat",
"linkTelegram": "Länka Telegram",
"contactEmail": "Kontakta via e-post",
"contactTelegram": "Kontakta via Telegram",
"refresh": "Uppdatera",
"required": "Obligatoriskt",
"contactDiscord": "Kontakt via Discord",
"linkMatrix": "Länka Matrix",
"reEnable": "Återaktivera",
"disable": "Inaktivera",
"contactMethods": "Kontaktmetoder",
"accountStatus": "Kontostatus",
"notSet": "Inte inställt"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.", "errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.",
@ -46,5 +31,6 @@
"errorUnknown": "Okänt fel.", "errorUnknown": "Okänt fel.",
"error401Unauthorized": "Obehörig. Prova att uppdatera sidan.", "error401Unauthorized": "Obehörig. Prova att uppdatera sidan.",
"errorSaveSettings": "Det gick inte att spara inställningarna." "errorSaveSettings": "Det gick inte att spara inställningarna."
} },
"quantityStrings": {}
} }

View File

@ -29,7 +29,7 @@
"logout": "登出", "logout": "登出",
"admin": "管理员", "admin": "管理员",
"enabled": "已启用", "enabled": "已启用",
"disabled": "禁用", "disabled": "禁用",
"reEnable": "重新启用", "reEnable": "重新启用",
"disable": "禁用", "disable": "禁用",
"expiry": "到期", "expiry": "到期",
@ -40,8 +40,7 @@
"contactMethods": "联系方式", "contactMethods": "联系方式",
"accountStatus": "帐户状态", "accountStatus": "帐户状态",
"notSet": "未设置", "notSet": "未设置",
"myAccount": "我的帐户", "myAccount": "我的帐户"
"referrals": "推荐"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "用户名/密码留空。", "errorLoginBlank": "用户名/密码留空。",

View File

@ -1,77 +0,0 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"ifItWasNotYou": "Pokud jste to nebyl vy, ignorujte to.",
"helloUser": "Ahoj {username},",
"reason": "Důvod"
},
"userCreated": {
"name": "Vytvoření uživatele",
"title": "Upozornění: Uživatel vytvořen",
"aUserWasCreated": "Uživatel byl vytvořen pomocí kódu {code}.",
"time": "Čas",
"notificationNotice": "Poznámka: Zprávy s upozorněním lze přepínat na řídicím panelu správce."
},
"inviteExpiry": {
"name": "Platnost pozvánky",
"title": "Upozornění: Platnost pozvánky vypršela",
"inviteExpired": "Platnost pozvánky vypršela.",
"expiredAt": "Platnost kódu {code} vypršela v {time}.",
"notificationNotice": "Poznámka: Zprávy s upozorněním lze přepínat na řídicím panelu správce."
},
"passwordReset": {
"name": "Resetovat heslo",
"title": "Požadováno resetování hesla - Jellyfin",
"someoneHasRequestedReset": "Někdo nedávno požádal o reset hesla na Jellyfin.",
"ifItWasYou": "Pokud jste to byli vy, zadejte do výzvy níže uvedený kód PIN.",
"ifItWasYouLink": "Pokud jste to byli vy, klikněte na odkaz níže.",
"codeExpiry": "Platnost kódu vyprší {date} v {time} UTC, což je za {expiresInMinutes}.",
"pin": "PIN"
},
"userDeleted": {
"name": "Smazání uživatele",
"title": "Váš účet byl smazán - Jellyfin",
"yourAccountWasDeleted": "Váš účet Jellyfin byl smazán."
},
"userDisabled": {
"name": "Uživatel zakázán",
"title": "Váš účet byl deaktivován - Jellyfin",
"yourAccountWasDisabled": "Váš účet byl deaktivován."
},
"userEnabled": {
"name": "Uživatel povolen",
"title": "Váš účet byl znovu aktivován - Jellyfin",
"yourAccountWasEnabled": "Váš účet byl znovu aktivován."
},
"inviteEmail": {
"name": "Pozvací e-mail",
"title": "Pozvat - Jellyfin",
"hello": "Ahoj",
"youHaveBeenInvited": "Byli jste pozváni do Jellyfinu.",
"toJoin": "Chcete-li se připojit, postupujte podle níže uvedeného odkazu.",
"inviteExpiry": "Platnost této pozvánky vyprší {date} v {time}, což je za {expiresInMinutes}, proto jednejte rychle.",
"linkButton": "Nastavte si účet"
},
"welcomeEmail": {
"name": "Vítejte",
"title": "Vítejte v Jellyfin",
"welcome": "Vítejte v Jellyfin!",
"youCanLoginWith": "Přihlásit se můžete pomocí níže uvedených údajů",
"yourAccountWillExpire": "Platnost vašeho účtu vyprší dne {date}.",
"jellyfinURL": "URL"
},
"emailConfirmation": {
"name": "Potvrzující email",
"title": "Potvrďte svůj email - Jellyfin",
"clickBelow": "Kliknutím na odkaz níže potvrďte svou e-mailovou adresu a začněte používat Jellyfin.",
"confirmEmail": "Potvrdit email"
},
"userExpired": {
"name": "Vypršení platnosti uživatele",
"title": "Platnost vašeho účtu vypršela Jellyfin",
"yourAccountHasExpired": "Platnost vašeho účtu vypršela.",
"contactTheAdmin": "Pro více informací kontaktujte administrátora."
}
}

View File

@ -45,13 +45,6 @@
"title": "Your account has been re-enabled - Jellyfin", "title": "Your account has been re-enabled - Jellyfin",
"yourAccountWasEnabled": "Your account was re-enabled." "yourAccountWasEnabled": "Your account was re-enabled."
}, },
"userExpiryAdjusted": {
"name": "Expiry adjusted",
"title": "Account expiry adjusted - Jellyfin",
"yourExpiryWasAdjusted": "Your account's expiry date has been adjusted.",
"ifPreviouslyDisabled": "If your account was previously disabled, it may have been re-enabled.",
"newExpiry": "Your account will now expire on {date}."
},
"inviteEmail": { "inviteEmail": {
"name": "Invite email", "name": "Invite email",
"title": "Invite - Jellyfin", "title": "Invite - Jellyfin",

View File

@ -1,6 +1,6 @@
{ {
"meta": { "meta": {
"name": "Magyar (HU)" "name": "Angol (US)"
}, },
"strings": { "strings": {
"ifItWasNotYou": "", "ifItWasNotYou": "",

View File

@ -1,88 +0,0 @@
{
"meta": {
"name": "کوردی سۆرانی"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری جێڵیفن",
"createAccountHeader": "دروستکردنی هەژمار",
"accountDetails": "زانیارییەکان",
"emailAddress": "ئیمەیل",
"username": "ناوی بەکارهێنەر",
"oldPassword": "وشەی نهێنی کۆن",
"newPassword": "وشەی نهێنی نوێ",
"password": "وشەی نهێنی",
"reEnterPassword": "دووبارە وشەی نهێنی بنووسەوە",
"reEnterPasswordInvalid": "وشە نهێنییەکان یەک ناگرن.",
"createAccountButton": "هەژمار دروستبکە",
"passwordRequirementsHeader": "داواکارییەکانی وشەی نهێنی",
"successHeader": "سەرکەوت!",
"confirmationRequired": "دووپاتکردنەوەی ئیمەیل داواکراوە",
"confirmationRequiredMessage": "تکایە سەیری نامەکانی ئیمەیلەکەت بکە بۆ دووپاتکردنەوەی ناونیشانەکەت.",
"yourAccountIsValidUntil": "هەژمارەکەت تاکو {date} کاردەکات.",
"sendPIN": "ئەم ژمارە نهێنییەی خوارەوە بۆ بۆتەکە بنێرە، پاشان وەرەوە بۆ پەیوەستکردنی هەژمارەکەت.",
"sendPINDiscord": "{command} لە چەناڵی {server_channel}ی دیسکۆردەکەت بنوسە، پاشان ئەم ژمارە نهێنییەی خوارەوە بنێرە.",
"matrixEnterUser": "",
"welcomeUser": "{user}، بەخێربێیت!",
"addContactMethod": "",
"editContactMethod": "",
"joinTheServer": "",
"customMessagePlaceholderHeader": "",
"customMessagePlaceholderContent": "",
"userPageSuccessMessage": "",
"resetPassword": "",
"resetPasswordThroughJellyfin": "",
"resetPasswordThroughLink": "",
"resetPasswordThroughLinkStart": "",
"resetPasswordThroughLinkEnd": "",
"resetPasswordUsername": "",
"resetPasswordEmail": "",
"resetPasswordContactMethod": "",
"resetSent": "",
"resetSentDescription": "",
"changePassword": "",
"referralsDescription": "",
"referralsWithExpiryDescription": "",
"copyReferral": "",
"invitedBy": ""
},
"notifications": {
"errorUserExists": "",
"errorInvalidCode": "",
"errorAccountLinked": "",
"errorEmailLinked": "",
"errorTelegramVerification": "",
"errorDiscordVerification": "",
"errorMatrixVerification": "",
"errorInvalidPIN": "",
"errorUnknown": "",
"errorNoEmail": "",
"errorCaptcha": "",
"errorPassword": "",
"errorNoMatch": "",
"errorOldPassword": "",
"passwordChanged": "",
"verified": ""
},
"validationStrings": {
"length": {
"singular": "",
"plural": ""
},
"uppercase": {
"singular": "",
"plural": ""
},
"lowercase": {
"singular": "",
"plural": ""
},
"number": {
"singular": "",
"plural": ""
},
"special": {
"singular": "",
"plural": ""
}
}
}

View File

@ -1,82 +0,0 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"pageTitle": "Vytvořte účet Jellyfin",
"createAccountHeader": "Vytvořit účet",
"accountDetails": "Podrobnosti",
"emailAddress": "Email",
"username": "Uživatelské jméno",
"oldPassword": "Staré heslo",
"newPassword": "Nové heslo",
"password": "Heslo",
"reEnterPassword": "Znovu zadejte heslo",
"reEnterPasswordInvalid": "Hesla nejsou stejná.",
"createAccountButton": "Vytvořit účet",
"passwordRequirementsHeader": "Požadavky na heslo",
"successHeader": "Hotovo!",
"confirmationRequired": "Vyžaduje se potvrzení e-mailem",
"confirmationRequiredMessage": "Zkontrolujte prosím svou e-mailovou schránku a ověřte svou adresu.",
"yourAccountIsValidUntil": "Váš účet bude platný do {date}.",
"sendPIN": "Odešlete robotovi níže uvedený PIN a poté se sem vraťte a propojte svůj účet.",
"sendPINDiscord": "Napište {command} do {server_channel} na Discordu a poté odešlete PIN níže.",
"matrixEnterUser": "Zadejte své uživatelské ID, stiskněte Odeslat a bude vám zaslán PIN. Chcete-li pokračovat, zadejte jej zde.",
"welcomeUser": "Vítejte, {user}!",
"addContactMethod": "Přidat metodu kontaktu",
"editContactMethod": "Upravit metodu kontaktu",
"joinTheServer": "Připojte se na server:",
"customMessagePlaceholderHeader": "Přizpůsobte si tuto kartu",
"customMessagePlaceholderContent": "Kliknutím na tlačítko upravit stránku uživatele v nastavení můžete přizpůsobit tuto kartu nebo ji zobrazit na přihlašovací obrazovce a nebojte se, uživatel to nevidí.",
"userPageSuccessMessage": "Podrobnosti o svém účtu můžete později zobrazit a změnit na stránce {myAccount}.",
"resetPassword": "Obnovit heslo",
"resetPasswordThroughJellyfin": "Chcete-li obnovit heslo, navštivte {jfLink} a stiskněte tlačítko \"Zapomenuté heslo\".",
"resetPasswordThroughLink": "Chcete-li obnovit heslo, zadejte své uživatelské jméno, e-mailovou adresu nebo uživatelské jméno propojené kontaktní metody a odešlete. Bude odeslán odkaz pro resetování hesla.",
"resetSent": "Resetování odesláno.",
"resetSentDescription": "Pokud existuje účet s daným uživatelským jménem/způsobem kontaktu, byl prostřednictvím všech dostupných způsobů kontaktu odeslán odkaz pro resetování hesla. Platnost kódu vyprší za 30 minut.",
"changePassword": "Změnit heslo",
"referralsDescription": "Pozvěte přátele a rodinu do Jellyfin pomocí tohoto odkazu. Vraťte se sem pro nový, pokud vyprší.",
"copyReferral": "Kopírovat odkaz",
"invitedBy": "Pozval vás uživatel {user}."
},
"notifications": {
"errorUserExists": "Uživatel již existuje.",
"errorInvalidCode": "Neplatný zvací kód.",
"errorAccountLinked": "Účet se již používá.",
"errorEmailLinked": "Email je již používán.",
"errorTelegramVerification": "Je vyžadováno ověření telegramem.",
"errorDiscordVerification": "Vyžaduje se ověření neshody.",
"errorMatrixVerification": "Vyžaduje se ověření matice.",
"errorInvalidPIN": "PIN je neplatný.",
"errorUnknown": "Neznámá chyba.",
"errorNoEmail": "Email je vyžadován.",
"errorCaptcha": "Captcha je nesprávná.",
"errorPassword": "Zkontrolujte požadavky na heslo.",
"errorNoMatch": "Hesla se neshodují.",
"errorOldPassword": "Staré heslo je nesprávné.",
"passwordChanged": "Heslo změněno.",
"verified": "Účet ověřen."
},
"validationStrings": {
"length": {
"singular": "Musí mít alespoň {n} znak",
"plural": "Musí mít nejméně {n} znaků"
},
"uppercase": {
"singular": "Musí mít alespoň {n} velkých písmen",
"plural": "Musí obsahovat alespoň {n} velkých písmen"
},
"lowercase": {
"singular": "Musí mít alespoň {n} malých písmen",
"plural": "Musí obsahovat alespoň {n} malých písmen"
},
"number": {
"singular": "Musí mít alespoň {n} číslo",
"plural": "Musí mít alespoň {n} čísel"
},
"special": {
"singular": "Musí mít alespoň {n} speciálních znaků",
"plural": "Musí obsahovat alespoň {n} speciálních znaků"
}
}
}

Some files were not shown because too many files have changed in this diff Show More