mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-19 00:57:37 +01:00
Compare commits
109 Commits
backups
...
jellyseerr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d60dea61db | ||
|
|
a136800ff2 | ||
|
|
db1c62cc46 | ||
|
|
1fa340f096 | ||
|
|
2a6937228c | ||
|
|
785395dd20 | ||
|
|
385953b0cb | ||
|
|
35f8337a36 | ||
|
|
769a7c45da | ||
|
|
a97bccc88f | ||
|
|
7b9cdf385a | ||
|
|
73e985c45c | ||
|
|
9c34192b4f | ||
|
|
dabef831d7 | ||
|
|
e44d11c58c | ||
|
|
48a2058e81 | ||
|
|
cd98e51ea9 | ||
|
|
fbbb03a47d | ||
|
|
6f5fc0948a | ||
|
|
05d473dc97 | ||
|
|
e9e361ae60 | ||
|
|
98d9bc62ff | ||
|
|
6f0f6e6901 | ||
|
|
c5d45355a8 | ||
|
|
1a85feb344 | ||
|
|
c75418db67 | ||
|
|
7c8e463929 | ||
|
|
8f04f49086 | ||
|
|
ec2f826dec | ||
|
|
6b576f2ffe | ||
|
|
7c989fda08 | ||
|
|
a4d436b16b | ||
|
|
9339992693 | ||
|
|
214d16cf0e | ||
|
|
a085e91cc6 | ||
|
|
272c38e0c5 | ||
|
|
6052329c0b | ||
|
|
acfdcdbc63 | ||
|
|
a7529c7498 | ||
|
|
c85a7843d0 | ||
|
|
186bf30eca | ||
|
|
45e74f6e33 | ||
|
|
59654b72e6 | ||
|
|
d5531ed73e | ||
|
|
ae208a87e0 | ||
|
|
0a56f7ceed | ||
|
|
9678e5cc1a | ||
|
|
e4b335f4f6 | ||
|
|
b5ae5f94fd | ||
|
|
867aad7896 | ||
|
|
97f42b2f37 | ||
|
|
59fbfdc8f3 | ||
|
|
c8b89f412b | ||
|
|
f4038f00ed | ||
|
|
8091d4cba6 | ||
|
|
189b1055e1 | ||
|
|
2c00f7e5e6 | ||
|
|
c2f592272d | ||
|
|
3fedc42a4a | ||
|
|
3c5826ae2f | ||
|
|
45d90f7459 | ||
|
|
d40acc855a | ||
|
|
8ee5377910 | ||
|
|
78c07aad3e | ||
|
|
9df2a82b6d | ||
|
|
11eae035d9 | ||
|
|
66e6b68b8c | ||
|
|
37156979d1 | ||
|
|
d7c94edc61 | ||
|
|
46566fb98c | ||
|
|
010b95a2f6 | ||
|
|
8f2a28e650 | ||
|
|
8a6102b7b9 | ||
|
|
0ce5c9923d | ||
|
|
4073ebe534 | ||
|
|
387fe082ef | ||
|
|
ddc36ae897 | ||
|
|
c62876ff3a | ||
|
|
2fd71acbb2 | ||
|
|
4c1d8ed2a1 | ||
|
|
7223981280 | ||
|
|
47536f3e63 | ||
|
|
ac4fecd819 | ||
|
|
b75bd4d6c5 | ||
|
|
2be7baea4a | ||
|
|
d56d45a404 | ||
|
|
b50d66d265 | ||
|
|
aec0a5349a | ||
|
|
20560332ed | ||
|
|
202ee0977e | ||
|
|
f460bfcfc6 | ||
|
|
4f5d12f800 | ||
|
|
9092b98b28 | ||
|
|
0f72a85724 | ||
|
|
0840931fed | ||
|
|
00379824df | ||
|
|
f823705e40 | ||
|
|
269836fc99 | ||
|
|
49d8c6f8e4 | ||
|
|
278588ca39 | ||
|
|
ab05c07469 | ||
|
|
04c94ba55a | ||
|
|
6e205760c3 | ||
|
|
82032b98a8 | ||
|
|
e8666d5bf2 | ||
|
|
d1affe271c | ||
|
|
ea109c7b63 | ||
|
|
cb5a8c1c23 | ||
|
|
7f518f55b2 |
@@ -45,9 +45,9 @@ type: docker
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
settings:
|
||||
host:
|
||||
from_secret: ssh2_host
|
||||
@@ -55,23 +55,20 @@ steps:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
key_path: /root/drone_rsa
|
||||
envs:
|
||||
- buildrone_key
|
||||
key:
|
||||
from_secret: ssh2_key
|
||||
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'
|
||||
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && 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
|
||||
@@ -88,7 +85,7 @@ steps:
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
|
||||
- ./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"'
|
||||
@@ -128,9 +125,6 @@ type: docker
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
@@ -141,11 +135,10 @@ steps:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
envs:
|
||||
- buildrone_key
|
||||
key_path: /root/drone_rsa
|
||||
key:
|
||||
from_secret: ssh2_key
|
||||
command_timeout: 50m
|
||||
script:
|
||||
- /mnt/buildx/jfa-go/build.sh
|
||||
@@ -160,10 +153,6 @@ trigger:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/docker-build
|
||||
---
|
||||
name: jfa-go-pr
|
||||
kind: pipeline
|
||||
@@ -175,7 +164,7 @@ steps:
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
|
||||
|
||||
trigger:
|
||||
event:
|
||||
@@ -1,3 +1,5 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
project_name: jfa-go
|
||||
release:
|
||||
github:
|
||||
@@ -78,6 +80,9 @@ builds:
|
||||
dir: ./
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-gcc
|
||||
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
|
||||
flags:
|
||||
- -tags=tray
|
||||
ldflags:
|
||||
|
||||
51
.woodpecker/git-binary.yaml
Normal file
51
.woodpecker/git-binary.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
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: 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: 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: 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'
|
||||
29
.woodpecker/git-docker.yaml
Normal file
29
.woodpecker/git-docker.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
|
||||
41
.woodpecker/stable-binary.yaml
Normal file
41
.woodpecker/stable-binary.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
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'
|
||||
29
.woodpecker/stable-docker.yaml
Normal file
29
.woodpecker/stable-docker.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
|
||||
@@ -3,42 +3,5 @@ title: "Building/Contributing for developers"
|
||||
date: 2021-07-25T00:33:36+01:00
|
||||
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.
|
||||
|
||||
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.
|
||||
[See the wiki page](https://wiki.jfa-go.com/docs/dev/).
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +1,22 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:latest AS support
|
||||
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
|
||||
# FROM --platform=$BUILDPLATFORM golang:latest AS support
|
||||
FROM --platform=$BUILDPLATFORM hrfee/jfa-go-build-docker AS support
|
||||
|
||||
COPY . /opt/build
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
|
||||
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
|
||||
&& apt-get install nodejs \
|
||||
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
|
||||
# Uncomment this if hrfee/jfa-go-build-docker doesn't support your architecture
|
||||
# RUN apt-get update -y \
|
||||
# && apt-get install build-essential python3-pip -y \
|
||||
# && (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
|
||||
# && apt-get install nodejs
|
||||
RUN (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 --platform=$BUILDPLATFORM golang:latest AS build
|
||||
ARG TARGETARCH
|
||||
ENV GOARCH=$TARGETARCH
|
||||
ARG BUILT_BY
|
||||
ENV BUILTBY=$BUILT_BY
|
||||
|
||||
COPY --from=support /opt/build /opt/build
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -13,7 +13,7 @@
|
||||
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?
|
||||
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.
|
||||
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
|
||||
@@ -22,32 +22,25 @@ If you want a bit more of a guarantee of support, I've seen these projects menti
|
||||
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
|
||||
* [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 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
|
||||
😂).
|
||||
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.
|
||||
|
||||
#### 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.
|
||||
* Send invites via a link and/or email
|
||||
* Send invites via a link and/or email, discord, telegram or matrix
|
||||
* 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.
|
||||
* Password validation: Ensure users choose a strong password.
|
||||
* CAPTCHAs can be enabled to avoid bots
|
||||
* CAPTCHAs and contact method verificatoin 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.
|
||||
* 🔗 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: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
|
||||
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. See [wiki](https://wiki.jfa-go.com/docs/ombi/) for a warning on this one.
|
||||
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
|
||||
* 📣 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.
|
||||
* "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.
|
||||
* 📨 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
|
||||
* "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.
|
||||
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
|
||||
* 🔑 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.
|
||||
* 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
|
||||
* Customize emails with variables and markdown
|
||||
* Specify contact and help messages to appear in emails and pages
|
||||
@@ -72,7 +65,7 @@ docker create \
|
||||
-p 8056:8056 \
|
||||
# -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/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
|
||||
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
|
||||
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
|
||||
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
|
||||
```
|
||||
@@ -80,7 +73,7 @@ docker create \
|
||||
##### [Debian/Ubuntu](https://apt.hrfee.dev)
|
||||
```sh
|
||||
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
|
||||
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
|
||||
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
|
||||
|
||||
# For stable releases
|
||||
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
|
||||
@@ -108,7 +101,7 @@ Available on the AUR as:
|
||||
##### Other platforms
|
||||
Download precompiled binaries from:
|
||||
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
|
||||
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
|
||||
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
|
||||
|
||||
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`.
|
||||
@@ -147,6 +140,8 @@ Usage of jfa-go:
|
||||
alternate port to host web ui on.
|
||||
-pprof
|
||||
Exposes pprof profiler on /debug/pprof.
|
||||
-restore string
|
||||
path to database backup to restore.
|
||||
-swagger
|
||||
Enable swagger at /swagger/index.html
|
||||
```
|
||||
@@ -154,18 +149,9 @@ Usage of jfa-go:
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
|
||||
##### Translation
|
||||
[](https://weblate.jfa-go.com/engage/jfa-go/)
|
||||
|
||||
@@ -175,4 +161,3 @@ 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:
|
||||
|
||||
[<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)
|
||||
|
||||
@@ -138,6 +138,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
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
|
||||
|
||||
@@ -102,7 +102,7 @@ func (app *appContext) checkInvites() {
|
||||
InviteCode: data.Code,
|
||||
Value: data.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, nil, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, nil, false)
|
||||
} else if used {
|
||||
del := false
|
||||
newInv := inv
|
||||
@@ -174,7 +174,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, nil, false)
|
||||
} else if newInv.RemainingUses != 0 {
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses--
|
||||
@@ -285,7 +285,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
InviteCode: invite.Code,
|
||||
Value: invite.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -305,7 +305,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
if inv.IsReferral {
|
||||
continue
|
||||
}
|
||||
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
months += years * 12
|
||||
invite := inviteDTO{
|
||||
Code: inv.Code,
|
||||
Months: months,
|
||||
@@ -492,7 +493,7 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
InviteCode: req.Code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
app.info.Printf("%s: Invite deleted", req.Code)
|
||||
respondBool(200, true, gc)
|
||||
|
||||
100
api-jellyseerr.go
Normal file
100
api-jellyseerr.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @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) {
|
||||
app.debug.Println("Jellyseerr users requested")
|
||||
users, err := app.js.GetUsers()
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to get users from Jellyseerr: %v", err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
|
||||
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("Couldn't get user from Jellyseerr: %v", 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("Couldn't get user's notification prefs from Jellyseerr: %v", 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)
|
||||
}
|
||||
149
api-messages.go
149
api-messages.go
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
@@ -26,18 +27,20 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
adminLang = app.storage.lang.chosenAdminLang
|
||||
}
|
||||
list := emailListDTO{
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").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},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").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},
|
||||
"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},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").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},
|
||||
"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},
|
||||
"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},
|
||||
"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},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").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")
|
||||
@@ -51,39 +54,6 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
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.
|
||||
// @Produce json
|
||||
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
|
||||
@@ -177,7 +147,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
} else if id == "UserLogin" {
|
||||
variables = []string{}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "PostSignupCard" {
|
||||
variables = []string{"{username}", "{myAccountURL}"}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
@@ -217,6 +191,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
msg, err = app.email.constructEnabled("", app, true)
|
||||
}
|
||||
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":
|
||||
if noContent {
|
||||
msg, err = app.email.constructInvite("", Invite{}, app, true)
|
||||
@@ -237,14 +216,14 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
msg, err = app.email.constructUserExpired(app, true)
|
||||
}
|
||||
values = app.email.userExpiredValues(app, false)
|
||||
case "UserLogin", "UserPage":
|
||||
case "UserLogin", "UserPage", "PostSignupCard":
|
||||
values = map[string]interface{}{}
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
|
||||
content = msg.Text
|
||||
variables = make([]string, strings.Count(content, "{"))
|
||||
i := 0
|
||||
@@ -270,17 +249,32 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, customMessage)
|
||||
var mail *Message
|
||||
if id != "UserLogin" && id != "UserPage" {
|
||||
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
|
||||
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
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 {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
HTML: "<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})
|
||||
}
|
||||
@@ -329,6 +323,14 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
tgUser.Lang = lang
|
||||
}
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -353,6 +355,7 @@ func (app *appContext) SetContactMethods(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 {
|
||||
change := tgUser.Contact != req.Telegram
|
||||
tgUser.Contact = req.Telegram
|
||||
@@ -363,6 +366,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
|
||||
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
|
||||
}
|
||||
}
|
||||
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
|
||||
@@ -375,6 +379,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
|
||||
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
|
||||
}
|
||||
}
|
||||
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
|
||||
@@ -399,6 +404,13 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
|
||||
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
|
||||
}
|
||||
}
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
err := app.js.ModifyNotifications(req.ID, jsPrefs)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to sync contact prefs with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
@@ -573,6 +585,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
|
||||
// @Router /matrix/login [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
var req MatrixLoginDTO
|
||||
@@ -608,6 +621,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
|
||||
// @Router /users/matrix [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
var req MatrixConnectUserDTO
|
||||
@@ -639,6 +653,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param username path string true "username to search."
|
||||
// @Router /users/discord/{username} [get]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
name := gc.Param("username")
|
||||
@@ -665,6 +680,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
|
||||
// @Router /users/discord [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
var req DiscordConnectUserDTO
|
||||
@@ -681,6 +697,13 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: req.JellyfinID,
|
||||
@@ -688,7 +711,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
respondBool(200, true, gc)
|
||||
@@ -699,6 +722,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/discord [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -710,6 +734,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
} */
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
@@ -717,7 +749,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -727,6 +759,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/telegram [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -738,6 +771,13 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
} */
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
@@ -745,7 +785,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -755,6 +795,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/matrix [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -773,7 +814,7 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
@@ -95,7 +96,8 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
|
||||
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
var req ombiUser
|
||||
gc.BindJSON(&req)
|
||||
profileName := gc.Param("profile")
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
@@ -122,7 +124,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
|
||||
profileName := gc.Param("profile")
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
|
||||
@@ -27,6 +27,7 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
|
||||
LibraryAccess: p.LibraryAccess,
|
||||
FromUser: p.FromUser,
|
||||
Ombi: p.Ombi != nil,
|
||||
Jellyseerr: p.Jellyseerr.Enabled,
|
||||
ReferralsEnabled: false,
|
||||
}
|
||||
if referralsEnabled {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
@@ -200,14 +201,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
gc.Redirect(http.StatusSeeOther, "/my/account")
|
||||
return
|
||||
} else if target == UserEmailChange {
|
||||
emailStore, ok := app.storage.GetEmailsKey(id)
|
||||
if !ok {
|
||||
emailStore = EmailAddress{
|
||||
Contact: true,
|
||||
}
|
||||
}
|
||||
emailStore.Addr = claims["email"].(string)
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
app.modifyEmail(id, claims["email"].(string))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
@@ -216,18 +210,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, gc, true)
|
||||
|
||||
app.info.Println("Email list modified")
|
||||
gc.Redirect(http.StatusSeeOther, "/my/account")
|
||||
@@ -371,6 +354,13 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
}
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
@@ -378,7 +368,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -419,6 +409,13 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
}
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
@@ -426,7 +423,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -507,7 +504,7 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
delete(app.matrix.tokens, pin)
|
||||
respondBool(200, true, gc)
|
||||
@@ -522,6 +519,13 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
@@ -529,7 +533,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -543,6 +547,13 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
@@ -550,7 +561,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -571,7 +582,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -701,7 +712,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
func() {
|
||||
|
||||
240
api-users.go
240
api-users.go
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
@@ -55,7 +57,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
profile := app.storage.GetDefaultProfile()
|
||||
if req.Profile != "" && req.Profile != "none" {
|
||||
@@ -93,6 +95,29 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
app.info.Println("Created Ombi user")
|
||||
}
|
||||
}
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
// Gets existing user (not possible) or imports the given user.
|
||||
_, err := app.js.MustGetUser(id)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to create Jellyseerr user: %v", err)
|
||||
} else {
|
||||
app.info.Println("Created Jellyseerr user")
|
||||
}
|
||||
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
|
||||
}
|
||||
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
|
||||
}
|
||||
if emailEnabled {
|
||||
err = app.js.ModifyUser(id, map[jellyseerr.UserField]any{jellyseerr.FieldEmail: req.Email})
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
|
||||
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email)
|
||||
msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false)
|
||||
@@ -114,7 +139,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
type errorFunc func(gc *gin.Context)
|
||||
|
||||
// Used on the form & when a users email has been confirmed.
|
||||
func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
|
||||
func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) {
|
||||
existingUser, _, _ := app.jf.UserByName(req.Username, false)
|
||||
if existingUser.Name != "" {
|
||||
f = func(gc *gin.Context) {
|
||||
@@ -292,24 +317,24 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) {
|
||||
for address, settings := range invite.Notify {
|
||||
if settings["notify-creation"] {
|
||||
go func() {
|
||||
go func(addr string) {
|
||||
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct user creation notification: %v", req.Code, err)
|
||||
} else {
|
||||
// Check whether notify "address" is an email address of Jellyfin ID
|
||||
if strings.Contains(address, "@") {
|
||||
err = app.email.send(msg, address)
|
||||
// Check whether notify "addr" is an email address of Jellyfin ID
|
||||
if strings.Contains(addr, "@") {
|
||||
err = app.email.send(msg, addr)
|
||||
} else {
|
||||
err = app.sendByID(msg, address)
|
||||
err = app.sendByID(msg, addr)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to send user creation notification: %v", req.Code, err)
|
||||
} else {
|
||||
app.info.Printf("Sent user creation notification to %s", address)
|
||||
app.info.Printf("Sent user creation notification to %s", addr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,12 +356,16 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
InviteCode: invite.Code,
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
emailStore := EmailAddress{
|
||||
Addr: req.Email,
|
||||
Contact: (req.Email != ""),
|
||||
}
|
||||
// Only allow disabling of email contact if some other method is available.
|
||||
if req.DiscordContact || req.TelegramContact || req.MatrixContact {
|
||||
emailStore.Contact = req.EmailContact
|
||||
}
|
||||
|
||||
if invite.UserLabel != "" {
|
||||
emailStore.Label = invite.UserLabel
|
||||
@@ -467,6 +496,51 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
app.debug.Printf("Skipping Ombi: Profile \"%s\" was empty", invite.Profile)
|
||||
}
|
||||
}
|
||||
if invite.Profile != "" && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
if profile.Jellyseerr.Enabled {
|
||||
// Gets existing user (not possible) or imports the given user.
|
||||
_, err := app.js.MustGetUser(id)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to create Jellyseerr user: %v", err)
|
||||
} else {
|
||||
app.info.Println("Created Jellyseerr user")
|
||||
}
|
||||
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
|
||||
}
|
||||
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
|
||||
}
|
||||
contactMethods := map[jellyseerr.NotificationsField]any{}
|
||||
if emailEnabled {
|
||||
err = app.js.ModifyMainUserSettings(id, jellyseerr.MainUserSettings{Email: req.Email})
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
|
||||
} else {
|
||||
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
|
||||
}
|
||||
}
|
||||
if discordVerified {
|
||||
contactMethods[jellyseerr.FieldDiscord] = discordUser.ID
|
||||
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
|
||||
}
|
||||
if telegramVerified {
|
||||
u, _ := app.storage.GetTelegramKey(user.ID)
|
||||
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
|
||||
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
|
||||
}
|
||||
if emailEnabled || discordVerified || telegramVerified {
|
||||
err := app.js.ModifyNotifications(id, contactMethods)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.debug.Printf("Skipping Jellyseerr: Profile \"%s\" was empty", invite.Profile)
|
||||
}
|
||||
}
|
||||
if matrixVerified {
|
||||
matrixUser.Contact = req.MatrixContact
|
||||
delete(app.matrix.tokens, req.MatrixPIN)
|
||||
@@ -503,7 +577,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
|
||||
var req newUserDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: New user attempt", req.Code)
|
||||
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText) {
|
||||
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) {
|
||||
app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code)
|
||||
respond(400, "errorCaptcha", gc)
|
||||
return
|
||||
@@ -539,7 +613,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
f, success := app.newUser(req, false)
|
||||
f, success := app.newUser(req, false, gc)
|
||||
if !success {
|
||||
f(gc)
|
||||
return
|
||||
@@ -609,7 +683,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
@@ -687,7 +761,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: username,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, false)
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
@@ -738,6 +812,22 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
|
||||
expiry.Expiry = base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
|
||||
}
|
||||
app.storage.SetUserExpiryKey(id, expiry)
|
||||
if messagesEnabled && req.Notify {
|
||||
go func(uid string, exp time.Time) {
|
||||
user, status, err := app.jf.UserByID(uid, false)
|
||||
if status != 200 || err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct expiry adjustment notification: %v", uid, err)
|
||||
return
|
||||
}
|
||||
if err := app.sendByID(msg, uid); err != nil {
|
||||
app.err.Printf("%s: Failed to send expiry adjustment notification: %v", uid, err)
|
||||
}
|
||||
}(id, expiry.Expiry)
|
||||
}
|
||||
}
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
@@ -927,12 +1017,17 @@ func (app *appContext) GetAnnounceTemplates(gc *gin.Context) {
|
||||
// @Produce json
|
||||
// @Success 200 {object} announcementTemplate
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Param name path string true "name of template"
|
||||
// @Param name path string true "name of template (url encoded if necessary)"
|
||||
// @Router /users/announce/template/{name} [get]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) GetAnnounceTemplate(gc *gin.Context) {
|
||||
name := gc.Param("name")
|
||||
escapedName := gc.Param("name")
|
||||
name, err := url.QueryUnescape(escapedName)
|
||||
if err != nil {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if announcement, ok := app.storage.GetAnnouncementsKey(name); ok {
|
||||
gc.JSON(200, announcement)
|
||||
return
|
||||
@@ -1162,6 +1257,44 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
func (app *appContext) modifyEmail(jfID string, addr string) {
|
||||
contactPrefChanged := false
|
||||
emailStore, ok := app.storage.GetEmailsKey(jfID)
|
||||
// Auto enable contact by email for newly added addresses
|
||||
if !ok || emailStore.Addr == "" {
|
||||
emailStore = EmailAddress{
|
||||
Contact: true,
|
||||
}
|
||||
contactPrefChanged = true
|
||||
}
|
||||
emailStore.Addr = addr
|
||||
app.storage.SetEmailsKey(jfID, emailStore)
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
ombiUser, code, err := app.getOmbiUser(jfID)
|
||||
if code == 200 && err == nil {
|
||||
ombiUser["emailAddress"] = addr
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
err := app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: addr})
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
|
||||
} else if contactPrefChanged {
|
||||
contactMethods := map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldEmailEnabled: true,
|
||||
}
|
||||
err := app.js.ModifyNotifications(jfID, contactMethods)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Modify user's email addresses.
|
||||
// @Produce json
|
||||
// @Param modifyEmailsDTO body modifyEmailsDTO true "Map of userIDs to email addresses"
|
||||
@@ -1180,22 +1313,10 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
|
||||
for _, jfUser := range users {
|
||||
id := jfUser.ID
|
||||
if address, ok := req[id]; ok {
|
||||
var emailStore = EmailAddress{}
|
||||
oldEmail, ok := app.storage.GetEmailsKey(id)
|
||||
if ok {
|
||||
emailStore = oldEmail
|
||||
}
|
||||
// Auto enable contact by email for newly added addresses
|
||||
if !ok || oldEmail.Addr == "" {
|
||||
emailStore.Contact = true
|
||||
}
|
||||
|
||||
emailStore.Addr = address
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
app.modifyEmail(id, address)
|
||||
|
||||
activityType := ActivityContactLinked
|
||||
if address == "" {
|
||||
@@ -1208,18 +1329,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if ombiEnabled {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
ombiUser["emailAddress"] = address
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, gc, false)
|
||||
}
|
||||
}
|
||||
app.info.Println("Email list modified")
|
||||
@@ -1243,6 +1353,8 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
var configuration mediabrowser.Configuration
|
||||
var displayprefs map[string]interface{}
|
||||
var ombi map[string]interface{}
|
||||
var jellyseerr JellyseerrTemplate
|
||||
jellyseerr.Enabled = false
|
||||
if req.From == "profile" {
|
||||
// Check profile exists & isn't empty
|
||||
profile, ok := app.storage.GetProfileKey(req.Profile)
|
||||
@@ -1260,12 +1372,19 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
configuration = profile.Configuration
|
||||
displayprefs = profile.Displayprefs
|
||||
}
|
||||
policy = profile.Policy
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
if req.Policy {
|
||||
policy = profile.Policy
|
||||
}
|
||||
if req.Ombi && app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
if profile.Ombi != nil && len(profile.Ombi) != 0 {
|
||||
ombi = profile.Ombi
|
||||
}
|
||||
}
|
||||
if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
if profile.Jellyseerr.Enabled {
|
||||
jellyseerr = profile.Jellyseerr
|
||||
}
|
||||
}
|
||||
|
||||
} else if req.From == "user" {
|
||||
applyingFrom = "user"
|
||||
@@ -1277,7 +1396,9 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
applyingFrom = "\"" + user.Name + "\""
|
||||
policy = user.Policy
|
||||
if req.Policy {
|
||||
policy = user.Policy
|
||||
}
|
||||
if req.Homescreen {
|
||||
displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
@@ -1293,6 +1414,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
"policy": map[string]string{},
|
||||
"homescreen": map[string]string{},
|
||||
"ombi": map[string]string{},
|
||||
"jellyseerr": map[string]string{},
|
||||
}
|
||||
/* Jellyfin doesn't seem to like too many of these requests sent in succession
|
||||
and can crash and mess up its database. Issue #160 says this occurs when more
|
||||
@@ -1303,9 +1425,13 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
app.debug.Println("Adding delay between requests for large batch")
|
||||
}
|
||||
for _, id := range req.ApplyTo {
|
||||
status, err := app.jf.SetPolicy(id, policy)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
|
||||
var status int
|
||||
var err error
|
||||
if req.Policy {
|
||||
status, err = app.jf.SetPolicy(id, policy)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
|
||||
}
|
||||
}
|
||||
if shouldDelay {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
@@ -1345,6 +1471,26 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
errors["ombi"][id] = errorString
|
||||
}
|
||||
}
|
||||
if jellyseerr.Enabled {
|
||||
errorString := ""
|
||||
// newUser := ombi
|
||||
// newUser["id"] = user["id"]
|
||||
// newUser["userName"] = user["userName"]
|
||||
// newUser["alias"] = user["alias"]
|
||||
// newUser["emailAddress"] = user["emailAddress"]
|
||||
err := app.js.ApplyTemplateToUser(id, jellyseerr.User)
|
||||
if err != nil {
|
||||
errorString += fmt.Sprintf("ApplyUser: %v ", err)
|
||||
}
|
||||
err = app.js.ApplyNotificationsTemplateToUser(id, jellyseerr.Notifications)
|
||||
if err != nil {
|
||||
errorString += fmt.Sprintf("ApplyNotifications: %v ", err)
|
||||
}
|
||||
if errorString != "" {
|
||||
errors["jellyseerr"][id] = errorString
|
||||
}
|
||||
}
|
||||
|
||||
if shouldDelay {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
12
api.go
12
api.go
@@ -114,6 +114,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
var req ResetPasswordDTO
|
||||
gc.BindJSON(&req)
|
||||
validation := app.validator.validate(req.Password)
|
||||
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
|
||||
valid := true
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
@@ -121,12 +122,18 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !valid || req.PIN == "" {
|
||||
// 200 bcs idk what i did in js
|
||||
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
isInternal := false
|
||||
|
||||
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
|
||||
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
|
||||
respond(400, "errorCaptcha", gc)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
if reset, ok := app.internalPWRs[req.PIN]; ok {
|
||||
isInternal = true
|
||||
@@ -138,6 +145,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
userID = reset.ID
|
||||
username = reset.Username
|
||||
|
||||
status, err := app.jf.ResetPasswordAdmin(userID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
@@ -179,7 +187,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}, gc, true)
|
||||
|
||||
prevPassword := req.PIN
|
||||
if isInternal {
|
||||
|
||||
45
auth.go
45
auth.go
@@ -18,6 +18,25 @@ const (
|
||||
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 {
|
||||
return app.authenticate
|
||||
}
|
||||
@@ -133,7 +152,7 @@ type getTokenDTO struct {
|
||||
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
|
||||
}
|
||||
|
||||
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
|
||||
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) {
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
auth, _ := base64.StdEncoding.DecodeString(header[1])
|
||||
creds := strings.SplitN(string(auth), ":", 2)
|
||||
@@ -141,7 +160,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
|
||||
password = creds[1]
|
||||
ok = false
|
||||
if username == "" || password == "" {
|
||||
app.debug.Println("Auth denied: blank username/password")
|
||||
app.logIpDebug(gc, userpage, "Auth denied: blank username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
@@ -149,17 +168,17 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, status, err := app.authJf.Authenticate(username, password)
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
|
||||
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
if status == 403 {
|
||||
app.info.Println("Auth denied: Jellyfin account disabled")
|
||||
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
|
||||
respond(403, "yourAccountWasDisabled", gc)
|
||||
return
|
||||
}
|
||||
@@ -180,8 +199,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
|
||||
// @tags Auth
|
||||
// @Security getTokenAuth
|
||||
func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
app.info.Println("Token requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc)
|
||||
app.logIpInfo(gc, false, "Token requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -196,12 +215,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !app.jellyfinLogin && !match {
|
||||
app.info.Println("Auth denied: Invalid username/password")
|
||||
app.logIpInfo(gc, false, "Auth denied: Invalid username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
if !match {
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc)
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -233,7 +252,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
|
||||
host := gc.Request.URL.Hostname()
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{token})
|
||||
}
|
||||
|
||||
@@ -284,7 +304,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.debug.Println("Token requested (refresh token)")
|
||||
app.logIpInfo(gc, false, "Token requested (refresh token)")
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
|
||||
if !ok {
|
||||
return
|
||||
@@ -297,6 +317,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
|
||||
host := gc.Request.URL.Hostname()
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
||||
15
backups.go
15
backups.go
@@ -161,20 +161,13 @@ func (app *appContext) loadPendingBackup() {
|
||||
LOADBAK = ""
|
||||
}
|
||||
|
||||
func newBackupDaemon(app *appContext) *housekeepingDaemon {
|
||||
func newBackupDaemon(app *appContext) *GenericDaemon {
|
||||
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
|
||||
daemon := housekeepingDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
}
|
||||
daemon.jobs = []func(app *appContext){
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.debug.Println("Backups: Creating backup")
|
||||
app.makeBackup()
|
||||
},
|
||||
}
|
||||
return &daemon
|
||||
)
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ func (app *appContext) loadConfig() error {
|
||||
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", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
|
||||
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
@@ -120,6 +123,9 @@ func (app *appContext) loadConfig() error {
|
||||
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))
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
// These two settings are pretty much the same
|
||||
url1 := app.config.Section("invite_emails").Key("url_base").String()
|
||||
url2 := app.config.Section("password_resets").Key("url_base").String()
|
||||
|
||||
@@ -173,22 +173,22 @@
|
||||
"description": "Enable this to use Jellyfin users instead of the below username and pw."
|
||||
},
|
||||
"admin_only": {
|
||||
"name": "Allow admin users only",
|
||||
"name": "Allow admin users only on \"Admin\" pages",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "jellyfin_login",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allows only admin users on Jellyfin to access the admin page."
|
||||
"description": "Allows only admin users on Jellyfin to access the admin page. Doesn't apply to the \"My Accounts\" page."
|
||||
},
|
||||
"allow_all": {
|
||||
"name": "Allow all users to login",
|
||||
"name": "Allow all users to login to \"Admin\" pages",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "jellyfin_login",
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead."
|
||||
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead. Doesn't apply to the \"My Accounts\" page."
|
||||
},
|
||||
"username": {
|
||||
"name": "Web Username",
|
||||
@@ -247,7 +247,7 @@
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "Your account has been created. Click below to continue to Jellyfin.",
|
||||
"description": "Displayed when a user creates an account"
|
||||
"description": "Displayed when a user creates an account. Use the \"post-signup card\" in the Message editor for more control."
|
||||
},
|
||||
"url_base": {
|
||||
"name": "Reverse Proxy subfolder",
|
||||
@@ -273,7 +273,7 @@
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"advanced": true,
|
||||
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\"."
|
||||
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\". Overrides the post-signup card."
|
||||
},
|
||||
"login_appearance": {
|
||||
"name": "Login screen appearance",
|
||||
@@ -297,6 +297,29 @@
|
||||
"advanced": true
|
||||
},
|
||||
"settings": {
|
||||
"log_ips": {
|
||||
"name": "Log IPs accessing Admin Page",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Log IP addresses of admins and admin page requests in console and in activities. See notice below on legality."
|
||||
},
|
||||
"log_ips_users": {
|
||||
"name": "Log IPs accessing User Page",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Log IP addresses of users in console and in activities. See notice below on legality."
|
||||
},
|
||||
"ip_note": {
|
||||
"name": "Logging IPs:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"required": "false",
|
||||
"description": "Logging IP addresses through jfa-go may violate GDPR or other privacy regulations, as IPs are linked to account information. Enable at your own risk."
|
||||
},
|
||||
"tls": {
|
||||
"name": "TLS/HTTP2",
|
||||
"required": false,
|
||||
@@ -597,7 +620,7 @@
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "User Page/\"My Account\"",
|
||||
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, sending referrals or changing their password. Password resets can also be initiated from here, given a contact method or username. ",
|
||||
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, sending referrals or changing their password. Password resets can also be initiated from here, given a contact method or username. Access control settings set in \"General\" do not apply to this page, nor does \"Access jfa-go\" in the Accounts tab.",
|
||||
"depends_true": "ui|jellyfin_login"
|
||||
},
|
||||
"settings": {
|
||||
@@ -679,7 +702,7 @@
|
||||
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
|
||||
},
|
||||
"pwr_note": {
|
||||
"name": "PWR Methods",
|
||||
"name": "PWR Methods:",
|
||||
"type": "note",
|
||||
"depends_true": "enabled",
|
||||
"value": "",
|
||||
@@ -1557,6 +1580,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Jellyseerr Integration",
|
||||
"description": "Connect to Jellyseerr to automatically trigger the import of users on account creation, and to automatically link contact methods (email, discord and telegram). A template must be added to a User Profile for accounts to be created."
|
||||
},
|
||||
"settings": {
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Enable the Jellyseerr integration."
|
||||
},
|
||||
"usertype_note": {
|
||||
"name": "Password Changes:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "Ensure existing users on Jellyseerr are \"Jellyfin User\"s not \"Local User\"s, as password changes are not synced with Jellyseerr."
|
||||
},
|
||||
"server": {
|
||||
"name": "URL",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "localhost:5000",
|
||||
"depends_true": "enabled",
|
||||
"description": "Jellyseerr server URL."
|
||||
},
|
||||
"api_key": {
|
||||
"name": "API Key",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"description": "API Key. Get this from the first tab in Jellyseerr's settings."
|
||||
},
|
||||
"import_existing": {
|
||||
"name": "Import existing users to Jellyseerr",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"depends_true": "enabled",
|
||||
"description": "Existing users (and those created outside jfa-go) will have their contact info imported to Jellyseerr."
|
||||
},
|
||||
"constraints_note": {
|
||||
"name": "Unique Emails:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "import_existing",
|
||||
"required": "false",
|
||||
"description": "Jellyseerr requires email addresses to be unique. If this is not the case, you may see errors in jfa-go's logs. You can require unique addresses in Settings > Email."
|
||||
}
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
@@ -1690,7 +1773,7 @@
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "User Expiry",
|
||||
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account."
|
||||
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account. Expiries can also be set and extended for invididual users, optionally with a message why."
|
||||
},
|
||||
"settings": {
|
||||
"behaviour": {
|
||||
@@ -1742,6 +1825,35 @@
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
},
|
||||
"adjustment_subject": {
|
||||
"name": "Adjustment: email subject",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Subject of adjustment emails, sent optionally when setting/extending an expiry."
|
||||
},
|
||||
"adjustment_email_html": {
|
||||
"name": "Adjustment: Custom email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"advanced": true,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email html"
|
||||
},
|
||||
"adjustment_email_text": {
|
||||
"name": "Adjustment: Custom email (plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"advanced": true,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
150
css/base.css
150
css/base.css
@@ -106,48 +106,6 @@ div.card:contains(section.banner.footer) {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -172,23 +130,7 @@ span.sm:not(.heading) {
|
||||
margin: .25rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who knows for half of these to be honest */
|
||||
@media screen and (max-width: 400px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@@ -219,69 +161,6 @@ sup.\~critical, .text-critical {
|
||||
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 {
|
||||
box-sizing: border-box; /* fixes weird length issue with inputs */
|
||||
}
|
||||
@@ -300,10 +179,6 @@ sup.\~critical, .text-critical {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -312,14 +187,6 @@ sup.\~critical, .text-critical {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-lp {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focused {
|
||||
display: block;
|
||||
}
|
||||
@@ -416,7 +283,16 @@ table {
|
||||
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 {
|
||||
margin-top: 0px;
|
||||
@@ -575,7 +451,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.g-recaptcha {
|
||||
overflow: hidden;
|
||||
width: 296px;
|
||||
@@ -587,3 +462,8 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
|
||||
.g-recaptcha iframe {
|
||||
margin: -2px 0px 0px -4px;
|
||||
}
|
||||
|
||||
.dropdown-manual-toggle {
|
||||
margin-bottom: -0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
.tooltip .content {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
max-width: 10rem;
|
||||
min-width: 6rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
@@ -13,12 +14,23 @@
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
transition: opacity 100ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
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 {
|
||||
left: 120%;
|
||||
}
|
||||
@@ -31,6 +43,10 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .content {
|
||||
.tooltip:hover .content,
|
||||
.tooltip:focus .content,
|
||||
.tooltip:focus-within .content
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
82
daemon.go
82
daemon.go
@@ -74,6 +74,17 @@ func (app *appContext) clearTelegram() {
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) clearPWRCaptchas() {
|
||||
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
|
||||
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("Housekeeping: Cleaning up Activity log...")
|
||||
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
|
||||
@@ -105,83 +116,42 @@ func (app *appContext) clearActivities() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
},
|
||||
func(app *appContext) { app.clearActivities() },
|
||||
}
|
||||
)
|
||||
|
||||
d.Name("Housekeeping daemon")
|
||||
|
||||
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)
|
||||
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
||||
|
||||
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
}
|
||||
|
||||
if clearEmail {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
|
||||
d.appendJobs(func(app *appContext) { app.clearEmails() })
|
||||
}
|
||||
if clearDiscord {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
|
||||
d.appendJobs(func(app *appContext) { app.clearDiscord() })
|
||||
}
|
||||
if clearTelegram {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
|
||||
d.appendJobs(func(app *appContext) { app.clearTelegram() })
|
||||
}
|
||||
if clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
|
||||
d.appendJobs(func(app *appContext) { app.clearMatrix() })
|
||||
}
|
||||
if clearPWR {
|
||||
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
|
||||
}
|
||||
|
||||
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)
|
||||
return d
|
||||
}
|
||||
|
||||
66
email.go
66
email.go
@@ -741,6 +741,72 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
|
||||
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{} {
|
||||
template := map[string]interface{}{
|
||||
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
|
||||
|
||||
65
genericdaemon.go
Normal file
65
genericdaemon.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// 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("%s started", 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)
|
||||
}
|
||||
11
go.mod
11
go.mod
@@ -16,8 +16,11 @@ replace github.com/hrfee/jfa-go/api => ./api
|
||||
|
||||
replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
|
||||
|
||||
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
|
||||
|
||||
require (
|
||||
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/fatih/color v1.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
@@ -28,12 +31,13 @@ require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/mediabrowser v0.3.12
|
||||
github.com/hrfee/mediabrowser v0.3.13
|
||||
github.com/itchyny/timefmt-go v0.1.5
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.1
|
||||
@@ -54,7 +58,6 @@ require (
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
@@ -87,7 +90,7 @@ require (
|
||||
github.com/google/uuid v1.3.0 // 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/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.6 // indirect
|
||||
|
||||
13
go.sum
13
go.sum
@@ -224,8 +224,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hrfee/mediabrowser v0.3.12 h1:fqDxt1be3e+ZNjAtlKc8MTqg7peo6fuGCrk2wOXo20k=
|
||||
github.com/hrfee/mediabrowser v0.3.12/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.13 h1:NgQNbq+JWwsP68BdWXL/rwbpfE/oO5LJ5KVkE+aNbX8=
|
||||
github.com/hrfee/mediabrowser v0.3.13/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||
@@ -394,8 +394,6 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
|
||||
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/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/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
@@ -437,8 +435,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
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.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
|
||||
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=
|
||||
@@ -478,8 +474,6 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
|
||||
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
|
||||
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=
|
||||
@@ -523,8 +517,6 @@ golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.6.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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -536,7 +528,6 @@ 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.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.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
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=
|
||||
|
||||
412
html/admin.html
412
html/admin.html
@@ -10,6 +10,7 @@
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
|
||||
window.usernameEnabled = {{ .username }};
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
window.linkResetEnabled = {{ .linkResetEnabled }};
|
||||
@@ -26,7 +27,7 @@
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
{{ template "login-modal.html" . }}
|
||||
<div id="modal-add-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href="">
|
||||
<span class="heading">{{ .strings.newUser }} <span class="modal-close">×</span></span>
|
||||
<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 }}">
|
||||
@@ -43,31 +44,31 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-about" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
|
||||
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
|
||||
<span class="heading"><span class="modal-close">×</span></span>
|
||||
<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.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>
|
||||
<div class="row col flex">
|
||||
<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 ~urge mt-4 mb-4 mr-2 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>
|
||||
<div class="dropdown mr-2" tabindex="0">
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
|
||||
<div class="flex flex-row flex-wrap gap-2 my-2">
|
||||
<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 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
|
||||
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
|
||||
<div class="dropdown" tabindex="0">
|
||||
<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>
|
||||
donate
|
||||
<span class="ml-2 chev"></span>
|
||||
</a>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<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-100 lang-link">Ko-fi</a>
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<pre class="font-mono bg-inherit">{{ .license }}</pre>
|
||||
@@ -80,46 +81,60 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-modify-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm: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">×</span></span>
|
||||
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
|
||||
<div class="flex flex-row mb-4">
|
||||
<label class="flex-row-group mr-2">
|
||||
<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>
|
||||
<div class="flex flex-col gap-4 my-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="grow">
|
||||
<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>
|
||||
</label>
|
||||
<label class="grow">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral @low unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-configuration" checked>
|
||||
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
|
||||
</label>
|
||||
<label class="flex-row-group ml-2">
|
||||
<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>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
</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>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4 unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{{ if .referralsEnabled }}
|
||||
<div id="modal-enable-referrals-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
|
||||
<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="">
|
||||
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
|
||||
<div class="flex flex-row mb-4">
|
||||
<label class="flex-row-group mr-2">
|
||||
<label class="grow mr-2">
|
||||
<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>
|
||||
</label>
|
||||
<label class="flex-row-group ml-2">
|
||||
<label class="grow ml-2">
|
||||
<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>
|
||||
</label>
|
||||
@@ -142,7 +157,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-enable-referrals-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
|
||||
<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="">
|
||||
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
|
||||
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
|
||||
@@ -162,7 +177,7 @@
|
||||
</div>
|
||||
{{ end }}
|
||||
<div id="modal-delete-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm: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">×</span></span>
|
||||
<div class="content mt-8">
|
||||
<label class="switch mb-4">
|
||||
@@ -178,7 +193,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-extend-expiry" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm: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">×</span></span>
|
||||
<div class="content mt-8">
|
||||
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
|
||||
@@ -242,7 +257,7 @@
|
||||
<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="">
|
||||
<span class="heading"><span id="header-announce"></span> <span class="modal-close">×</span></span>
|
||||
<div class="row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="col card ~neutral @low">
|
||||
<div id="announce-details">
|
||||
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
|
||||
@@ -259,7 +274,7 @@
|
||||
<input type="text" class="input ~neutral @low mb-2 mt-4">
|
||||
<p class="support">{{ .strings.templateEnterName }}</p>
|
||||
</label>
|
||||
<div class="row flex-expand">
|
||||
<div class="flex flex-row justify-between">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
|
||||
@@ -275,10 +290,10 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-customize" 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-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
<div class="">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -297,6 +312,7 @@
|
||||
<span class="heading"><span id="header-editor"></span> <span class="modal-close">×</span></span>
|
||||
<div class="row">
|
||||
<div class="col card ~neutral @low">
|
||||
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="aside-editor"></aside>
|
||||
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
|
||||
<div id="editor-variables" class="mt-4"></div>
|
||||
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
|
||||
@@ -319,7 +335,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-restart" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
|
||||
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
|
||||
<div class="float-right">
|
||||
@@ -329,7 +345,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-backups" 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-11/12 sm:w-4/5 lg:w-2/3">
|
||||
<span class="heading">{{ .strings.backups }} <span class="modal-close">×</span></span>
|
||||
<div class="content my-4">
|
||||
{{ .strings.backupsDescription }}
|
||||
@@ -345,7 +361,7 @@
|
||||
<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">
|
||||
<div class="overflow-x-auto text-xs md:text-sm">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -360,30 +376,30 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-backed-up" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<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">×</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-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
|
||||
<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 class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<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.settingsApplied }}</span>
|
||||
<p class="content">{{ .strings.settingsRefreshPage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-send-pwr" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<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.sendPWR }}</span>
|
||||
<p class="content my-2" id="send-pwr-note"></p>
|
||||
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-ombi-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
|
||||
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
@@ -395,8 +411,21 @@
|
||||
</label>
|
||||
</form>
|
||||
</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">×</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 class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
|
||||
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
@@ -408,6 +437,9 @@
|
||||
{{ if .ombiEnabled }}
|
||||
<th>Ombi</th>
|
||||
{{ end }}
|
||||
{{ if .jellyseerrEnabled }}
|
||||
<th>Jellyseerr</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th>{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
@@ -422,7 +454,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-add-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href="">
|
||||
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
|
||||
<label>
|
||||
@@ -445,7 +477,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-update" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
|
||||
<span class="heading">{{ .strings.updates }} <span class="modal-close">×</span></span>
|
||||
<p class="content">
|
||||
<h2 class="mt-2">
|
||||
@@ -461,7 +493,7 @@
|
||||
</div>
|
||||
{{ if .telegramEnabled }}
|
||||
<div id="modal-telegram" 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-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
|
||||
<p class="content mb-4">{{ .strings.sendPIN }}</p>
|
||||
<h1 class="ac" id="telegram-pin"></h1>
|
||||
@@ -479,7 +511,7 @@
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<div id="modal-discord" 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-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">×</span></span>
|
||||
<p class="content mb-4" id="discord-description"></p>
|
||||
<div class="row">
|
||||
@@ -490,7 +522,7 @@
|
||||
</div>
|
||||
{{ end }}
|
||||
<div id="modal-matrix" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
|
||||
<span class="heading">{{ .strings.linkMatrix }}</span>
|
||||
<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">
|
||||
@@ -503,7 +535,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<div class="top-4 left-4 absolute">
|
||||
<div class="top-4 left-4 absolute flex flex-row gap-2">
|
||||
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
@@ -532,8 +564,8 @@
|
||||
{{ end }}
|
||||
<div class="page-container">
|
||||
<div class="mb-4">
|
||||
<header class="flex flex-wrap items-center justify-between">
|
||||
<div>
|
||||
<header>
|
||||
<div class="flex flex-row overflow-x-scroll items-center">
|
||||
<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 mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
|
||||
@@ -547,55 +579,55 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-invites">
|
||||
<div class="card @low invites dark:~d_neutral mb-4">
|
||||
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible">
|
||||
<span class="heading">{{ .strings.invites }}</span>
|
||||
<div id="invites"></div>
|
||||
<div id="invites" class="mt-2"></div>
|
||||
</div>
|
||||
<div class="card @low dark:~d_neutral">
|
||||
<span class="heading">{{ .strings.create }}</span>
|
||||
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
|
||||
<div class="card ~neutral @low col">
|
||||
<div class="row mb-2">
|
||||
<label class="col mr-2">
|
||||
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
|
||||
<div class="card ~neutral @low flex flex-col gap-2 grow">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
|
||||
</label>
|
||||
<label class="col ml-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="inv-duration">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div id="inv-duration" class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
@@ -603,44 +635,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="user-expiry" class="unfocused">
|
||||
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
|
||||
<div class="mb-2">
|
||||
<label for="create-user-expiry-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
|
||||
<span class="ml-2">{{ .strings.enabled }} </span>
|
||||
</label>
|
||||
<div id="user-expiry" class="unfocused flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<p class="support">{{ .strings.userExpiryDescription }}</p>
|
||||
<div>
|
||||
<label for="create-user-expiry-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
|
||||
<span class="ml-2">{{ .strings.enabled }} </span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
@@ -648,77 +682,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
|
||||
<input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
|
||||
<input type="text" id="create-label" class="input ~neutral @low">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
|
||||
<p class="support">{{ .strings.userLabelDescription }}</p>
|
||||
<input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
|
||||
<p class="support">{{ .strings.userLabelDescription }}</p>
|
||||
</div>
|
||||
<input type="text" id="create-user-label" class="input ~neutral @low">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ~neutral @low col">
|
||||
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
|
||||
<div class="flex-expand mb-2 mt-4">
|
||||
<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">
|
||||
<span>∞</span>
|
||||
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
|
||||
</label>
|
||||
</div>
|
||||
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
|
||||
<label class="label supra">{{ .strings.profile }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="create-profile">
|
||||
</select>
|
||||
</div>
|
||||
<div id="create-send-to-container">
|
||||
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
|
||||
<div class="flex-expand mb-2 mt-4">
|
||||
{{ if .discordEnabled }}
|
||||
<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 mr-2">
|
||||
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
|
||||
</span>
|
||||
{{ else }}
|
||||
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
|
||||
{{ end }}
|
||||
<label for="create-send-to-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
|
||||
</label>
|
||||
<div class="card ~neutral @low flex flex-col justify-between gap-2 grow">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
|
||||
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
|
||||
<span>∞</span>
|
||||
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-profile">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="create-send-to-container" class="flex flex-col gap-4">
|
||||
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{{ if .discordEnabled }}
|
||||
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
|
||||
<span id="create-send-to-search" class="button ~neutral @low">
|
||||
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
|
||||
</span>
|
||||
{{ else }}
|
||||
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
|
||||
{{ end }}
|
||||
<label for="create-send-to-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-accounts" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
|
||||
<div class="flex-expand align-middle">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
|
||||
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
|
||||
<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="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<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>
|
||||
</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-[-2.64rem] rounded-s-none 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="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>
|
||||
<span id="accounts-filter-area"></span>
|
||||
</div>
|
||||
<div class="supra py-1 sm">{{ .strings.actions }}</div>
|
||||
<div class="row -mx-2">
|
||||
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
|
||||
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3 mb-4">
|
||||
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="supra sm">{{ .strings.templates }}</span>
|
||||
@@ -726,12 +774,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||
{{ if .referralsEnabled }}
|
||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
|
||||
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
|
||||
{{ end }}
|
||||
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
|
||||
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
<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 class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
|
||||
@@ -739,16 +787,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
|
||||
<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="card ~neutral @low">
|
||||
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
</div>
|
||||
<div class="card @low accounts-header table-responsive mt-2">
|
||||
<table class="table text-base leading-4">
|
||||
@@ -791,27 +839,33 @@
|
||||
</div>
|
||||
<div id="tab-activity" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
|
||||
<div class="flex-expand align-middle">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
|
||||
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
|
||||
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low mt-2" id="activity-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
<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>
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
<input type="search" class="field ~neutral @low input search ml-2 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 class="flex flex-row justify-between py-2">
|
||||
<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">
|
||||
<span id="activity-total-records" class="mx-2"></span>
|
||||
<span id="activity-loaded-records" class="mx-2"></span>
|
||||
<span id="activity-shown-records" class="mx-2"></span>
|
||||
<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">
|
||||
@@ -842,24 +896,24 @@
|
||||
</div>
|
||||
<div id="tab-settings" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral settings overflow">
|
||||
<div class="flex-expand">
|
||||
<div class="flex-row">
|
||||
<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>
|
||||
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
|
||||
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
|
||||
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
|
||||
<span class="ml-2">{{ .strings.advancedSettings }} </span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
|
||||
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
|
||||
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
||||
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
|
||||
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
|
||||
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
|
||||
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
||||
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
|
||||
<div class="flex-expand">
|
||||
<div class="flex flex-row justify-between">
|
||||
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
|
||||
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</section>
|
||||
<section class="section ~neutral @low">
|
||||
<div class="flex-expand">
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="subheading">Full Log</span>
|
||||
<span class="button ~urge ml-4" id="copy-log">Copy</span>
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,20 @@
|
||||
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
|
||||
window.userPageEnabled = {{ .userPageEnabled }};
|
||||
window.userPageAddress = "{{ .userPageAddress }}";
|
||||
{{ if index . "customSuccessCard" }}
|
||||
window.customSuccessCard = {{ .customSuccessCard }};
|
||||
{{ else }}
|
||||
window.customSuccessCard = false;
|
||||
{{ end }}
|
||||
</script>
|
||||
{{ if .passwordReset }}
|
||||
<script src="js/pwr.js" type="module"></script>
|
||||
<script>
|
||||
window.pwrPIN = "{{ .pwrPIN }}";
|
||||
</script>
|
||||
{{ else }}
|
||||
<script src="js/form.js" type="module"></script>
|
||||
{{ end }}
|
||||
{{ if .reCAPTCHA }}
|
||||
<script>
|
||||
var reCAPTCHACallback = () => {
|
||||
@@ -49,4 +58,3 @@
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -14,12 +14,19 @@
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-success" class="modal">
|
||||
<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>
|
||||
<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 }}
|
||||
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ 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">
|
||||
<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>
|
||||
{{ 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>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div id="modal-confirmation" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<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="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="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<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 class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
|
||||
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2">
|
||||
{{ $hasTwoCards := 0 }}
|
||||
{{ if index . "LoginMessageEnabled" }}
|
||||
{{ if .LoginMessageEnabled }}
|
||||
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
|
||||
{{ $hasTwoCards = 1 }}
|
||||
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
|
||||
{{ .LoginMessageContent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if index . "userPageEnabled" }}
|
||||
{{ if and .userPageEnabled .showUserPageLink }}
|
||||
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
|
||||
{{ if and .userPageEnabled .showUserPageLink }}
|
||||
{{ $hasTwoCards = 1 }}
|
||||
<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>
|
||||
<a class="button ~info h-12 w-100" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
|
||||
<form class="card mx-2 form-login w-full {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
|
||||
<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="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<aside class="aside ~warning">
|
||||
{{ .strings.changeYourPassword }}
|
||||
</aside>
|
||||
<span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
|
||||
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<i class="content">{{ .contactMessage }}</i>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
|
||||
</section>
|
||||
@@ -59,7 +59,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -150,7 +150,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -189,7 +189,7 @@
|
||||
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -235,7 +235,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
|
||||
@@ -258,7 +258,7 @@
|
||||
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
|
||||
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -273,7 +273,7 @@
|
||||
<input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -370,7 +370,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -392,7 +392,7 @@
|
||||
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -413,7 +413,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -450,7 +450,7 @@
|
||||
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -483,7 +483,7 @@
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -513,7 +513,7 @@
|
||||
<input type="text" class="input ~neutral @low mt-4" id="email-message">
|
||||
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
|
||||
@@ -109,10 +109,10 @@
|
||||
<div class="card @low dark:~d_neutral mb-4" id="card-user">
|
||||
<span class="heading mb-2"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
|
||||
{{ if index . "PageMessageEnabled" }}
|
||||
{{ if .PageMessageEnabled }}
|
||||
<div class="card @low dark:~d_neutral content" id="card-message">
|
||||
<div class="card @low dark:~d_neutral content break-words" id="card-message">
|
||||
{{ .PageMessageContent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -162,7 +162,7 @@
|
||||
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
|
||||
<span class="heading mb-2">{{ .strings.referrals }}</span>
|
||||
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
|
||||
<div class="row flex-expand">
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<div class="user-referrals-info"></div>
|
||||
<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>
|
||||
|
||||
7
jellyseerr/go.mod
Normal file
7
jellyseerr/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
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 // indirect
|
||||
460
jellyseerr/jellyseerr.go
Normal file
460
jellyseerr/jellyseerr.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package jellyseerr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 common.TimeoutHandler
|
||||
LogRequestBodies bool
|
||||
AutoImportUsers bool
|
||||
}
|
||||
|
||||
// NewJellyseerr returns an Ombi object.
|
||||
func NewJellyseerr(server, key string, timeoutHandler common.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,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
|
||||
defer js.timeoutHandler()
|
||||
var responseText string
|
||||
defer resp.Body.Close()
|
||||
if response || reqFailed {
|
||||
responseText, err = js.decodeResp(resp)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
if reqFailed {
|
||||
var msg ErrorDTO
|
||||
err = json.Unmarshal([]byte(responseText), &msg)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
if msg.Message == "" {
|
||||
err = fmt.Errorf("failed (error %d)", resp.StatusCode)
|
||||
} else {
|
||||
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, status, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
|
||||
var data []User
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(js.server+"/user", nil, params)
|
||||
var data GetUsersDTO
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
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, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
|
||||
var data User
|
||||
data.ID = -1
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data.Permissions, err
|
||||
}
|
||||
if status != 200 {
|
||||
return data.Permissions, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
|
||||
var data User
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
// Lazily just invalidate the cache.
|
||||
js.cacheExpiry = time.Now()
|
||||
return nil
|
||||
}
|
||||
69
jellyseerr/jellyseerr_test.go
Normal file
69
jellyseerr/jellyseerr_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
136
jellyseerr/models.go
Normal file
136
jellyseerr/models.go
Normal file
@@ -0,0 +1,136 @@
|
||||
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"`
|
||||
}
|
||||
81
jellyseerrdaemon.go
Normal file
81
jellyseerrdaemon.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
)
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
||||
user, imported, err := app.js.GetOrImportUser(jfID)
|
||||
if err != nil {
|
||||
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err)
|
||||
return
|
||||
}
|
||||
if imported {
|
||||
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID)
|
||||
}
|
||||
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
|
||||
if err != nil {
|
||||
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", 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("Failed to set Jellyseerr email address: %v\n", 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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUsers() {
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if err != nil || status != 200 {
|
||||
app.err.Printf("Failed to get users (%d): %s", status, 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 daemon")
|
||||
return d
|
||||
}
|
||||
25
lang.go
25
lang.go
@@ -93,18 +93,19 @@ func (ls *emailLangs) getOptions() [][2]string {
|
||||
}
|
||||
|
||||
type emailLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
UserDisabled langSection `json:"userDisabled"`
|
||||
UserEnabled langSection `json:"userEnabled"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
UserExpired langSection `json:"userExpired"`
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
UserDisabled langSection `json:"userDisabled"`
|
||||
UserEnabled langSection `json:"userEnabled"`
|
||||
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
UserExpired langSection `json:"userExpired"`
|
||||
}
|
||||
|
||||
type setupLangs map[string]setupLang
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"before": "قبل",
|
||||
"user": "مستخدم",
|
||||
"userExpiry": "انتهاء صلاحية المستخدم",
|
||||
"userExpiryDescription": "",
|
||||
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
|
||||
"aboutProgram": "حول",
|
||||
"version": "إصدار",
|
||||
"commitNoun": "تعديل",
|
||||
"commitNoun": "فرض",
|
||||
"newUser": "مستخدم جديد",
|
||||
"profile": "ملف",
|
||||
"profile": "حساب تعريفي",
|
||||
"unknown": "غير معروف",
|
||||
"label": "وسم",
|
||||
"logs": "السجلات",
|
||||
@@ -63,19 +63,19 @@
|
||||
"markdownSupported": "",
|
||||
"modifySettings": "",
|
||||
"modifySettingsDescription": "",
|
||||
"applyHomescreenLayout": "",
|
||||
"sendDeleteNotificationEmail": "",
|
||||
"sendDeleteNotifiationExample": "",
|
||||
"settingsRestart": "",
|
||||
"settingsRestarting": "",
|
||||
"settingsRestartRequired": "",
|
||||
"settingsRestartRequiredDescription": "",
|
||||
"settingsApplyRestartLater": "",
|
||||
"settingsApplyRestartNow": "",
|
||||
"settingsApplied": "",
|
||||
"settingsRefreshPage": "",
|
||||
"settingsRequiredOrRestartMessage": "",
|
||||
"settingsSave": "",
|
||||
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
|
||||
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
|
||||
"sendDeleteNotifiationExample": "تم حذف حسابك.",
|
||||
"settingsRestart": "اعاده تشغيل",
|
||||
"settingsRestarting": "اعاده التشغيل…",
|
||||
"settingsRestartRequired": "يجب اعاده التشغيل",
|
||||
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
|
||||
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
|
||||
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
|
||||
"settingsApplied": "تم تطبيق الاعدادات.",
|
||||
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
|
||||
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
|
||||
"settingsSave": "حفظ",
|
||||
"ombiProfile": "",
|
||||
"ombiUserDefaultsDescription": "",
|
||||
"userProfiles": "",
|
||||
@@ -117,7 +117,15 @@
|
||||
"userPageLogin": "",
|
||||
"userPagePage": "",
|
||||
"buildTime": "",
|
||||
"builtBy": ""
|
||||
"builtBy": "",
|
||||
"activity": "الانشطه",
|
||||
"userLabel": "وسم المستخدم",
|
||||
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
|
||||
"enableReferrals": "تفعيل الاحالات",
|
||||
"disableReferrals": "ابطال الاحالات",
|
||||
"invite": "دعوه",
|
||||
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
|
||||
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"invites": "Einladungen",
|
||||
"accounts": "Konten",
|
||||
"settings": "Einstellungen",
|
||||
"inviteDays": "Tage",
|
||||
@@ -94,7 +94,7 @@
|
||||
"accessJFA": "jfa-go Zugriff",
|
||||
"sendPWRValidFor": "Der Link ist 30m gültig.",
|
||||
"logs": "Logdaten",
|
||||
"setExpiry": "Ablauf setzen",
|
||||
"setExpiry": "Ablaufdatum setzen",
|
||||
"sendPWRSuccess": "Link zur Passwortrücksetzung versandt.",
|
||||
"sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.",
|
||||
"sendPWR": "Sende Passwortrücksetzung",
|
||||
@@ -115,7 +115,28 @@
|
||||
"after": "nach",
|
||||
"before": "vor",
|
||||
"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": {
|
||||
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
"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",
|
||||
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
|
||||
"applyOmbi": "Apply Ombi profile (if available)",
|
||||
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
|
||||
"sendDeleteNotificationEmail": "Send notification message",
|
||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||
"settingsRestart": "Restart",
|
||||
@@ -99,6 +102,8 @@
|
||||
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
|
||||
"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.",
|
||||
"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",
|
||||
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
|
||||
"userProfilesIsDefault": "Default",
|
||||
@@ -137,6 +142,8 @@
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPageLogin": "User Page: Login",
|
||||
"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",
|
||||
"builtBy": "Built By",
|
||||
"loginNotAdmin": "Not an Admin?",
|
||||
@@ -150,7 +157,8 @@
|
||||
"accountDisabled": "Account disabled: {user}",
|
||||
"accountReEnabled": "Account re-enabled: {user}",
|
||||
"accountExpired": "Account expired: {user}",
|
||||
"accountWillExpire": "Account will expire on {date}",
|
||||
"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}",
|
||||
@@ -205,6 +213,7 @@
|
||||
"sentAnnouncement": "Announcement sent.",
|
||||
"savedAnnouncement": "Announcement saved.",
|
||||
"setOmbiProfile": "Stored ombi profile.",
|
||||
"savedProfile": "Stored profile changes.",
|
||||
"updateApplied": "Update applied, please restart.",
|
||||
"updateAppliedRefresh": "Update applied, please refresh.",
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
@@ -221,6 +230,7 @@
|
||||
"errorDeleteProfile": "Failed to delete profile {n}",
|
||||
"errorLoadProfiles": "Failed to load profiles.",
|
||||
"errorCreateProfile": "Failed to create profile {n}",
|
||||
"errorSavedProfile": "Failed to save profile {n}",
|
||||
"errorSetDefaultProfile": "Failed to set default profile.",
|
||||
"errorLoadUsers": "Failed to load users.",
|
||||
"errorLoadSettings": "Failed to load settings.",
|
||||
|
||||
@@ -116,7 +116,27 @@
|
||||
"after": "Después",
|
||||
"before": "Antes",
|
||||
"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": {
|
||||
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
|
||||
|
||||
@@ -125,7 +125,78 @@
|
||||
"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."
|
||||
"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": {
|
||||
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
|
||||
@@ -165,7 +236,13 @@
|
||||
"setOmbiProfile": "Profil ombi enregistré.",
|
||||
"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."
|
||||
"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": {
|
||||
"modifySettingsFor": {
|
||||
|
||||
@@ -74,7 +74,9 @@
|
||||
"select": "Pilih",
|
||||
"search": "Cari",
|
||||
"download": "Unduh",
|
||||
"inviteMonths": "Bulan"
|
||||
"inviteMonths": "Bulan",
|
||||
"inviteDuration": "Durasi undangan",
|
||||
"activity": "Aktivitas"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Alamat email {n} diubah.",
|
||||
|
||||
@@ -130,7 +130,72 @@
|
||||
"searchOptions": "Zoekopties",
|
||||
"matchText": "Tekstovereenkomst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Gebruikerspagina: Pagina"
|
||||
"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": {
|
||||
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
|
||||
@@ -170,7 +235,13 @@
|
||||
"setOmbiProfile": "Opgeslagen ombi-profiel.",
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
|
||||
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
|
||||
"referralsEnabled": "Verwijzingen actief."
|
||||
"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": {
|
||||
"modifySettingsFor": {
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
"deleteTemplate": "Usuń szablon",
|
||||
"templateEnterName": "Wprowadź nazwę aby zapisać szablon.",
|
||||
"accessJFA": "",
|
||||
"accessJFASettings": ""
|
||||
"accessJFASettings": "",
|
||||
"invite": "Zaproś"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Zmieniono adres email {n}.",
|
||||
@@ -197,4 +198,4 @@
|
||||
"plural": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"conditionals": "Condicionais",
|
||||
"donate": "Doar",
|
||||
"contactThrough": "Contato através:",
|
||||
"sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.",
|
||||
"sendPIN": "Peça ao usuário para enviar o PIN abaixo para o bot.",
|
||||
"searchDiscordUser": "Digite o nome de usuário do 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.",
|
||||
@@ -102,7 +102,100 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"changedEmailAddress": "Endereço de e-mail alterado de {n}.",
|
||||
@@ -140,7 +233,15 @@
|
||||
"accountConnected": "Conta conectada.",
|
||||
"savedAnnouncement": "Anúncio salvo.",
|
||||
"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": {
|
||||
"modifySettingsFor": {
|
||||
@@ -198,6 +299,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Definir expiração para {a} usuário",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"ombiUserDefaults": "Ombi 用户默认值",
|
||||
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
|
||||
"userProfiles": "用户档案",
|
||||
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
|
||||
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
|
||||
"userProfilesIsDefault": "默认",
|
||||
"userProfilesLibraries": "库",
|
||||
"addProfile": "添加档案",
|
||||
@@ -117,7 +117,85 @@
|
||||
"before": "之前",
|
||||
"unlink": "取消关联帐户",
|
||||
"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": {
|
||||
"changedEmailAddress": "更改了 {n} 的电子邮件地址。",
|
||||
@@ -155,7 +233,15 @@
|
||||
"updateAvailable": "有新更新可用,请检查设置。",
|
||||
"noUpdatesAvailable": "没有可用的更新。",
|
||||
"setOmbiProfile": "保存ombi配置文件。",
|
||||
"errorSetOmbiProfile": "无法保存ombi配置文件。"
|
||||
"errorSetOmbiProfile": "无法保存ombi配置文件。",
|
||||
"activityDeleted": "活动已删除。",
|
||||
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
|
||||
"referralsEnabled": "已启用推荐。",
|
||||
"errorInviteNoLongerExists": "邀请已不存在。",
|
||||
"errorInviteNotFound": "未找到邀请。",
|
||||
"errorLoadActivities": "无法加载活动。",
|
||||
"pathCopied": "完整路径已复制到剪贴板。",
|
||||
"errorInvalidDate": "日期无效。"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -213,6 +299,10 @@
|
||||
"setExpiry": {
|
||||
"plural": "为{n}用户设置到期时间",
|
||||
"singular": "为{n}用户设置到期时间"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "为{n}用户启用推荐功能",
|
||||
"plural": "为{n}个用户启用推荐功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@
|
||||
"add": "Agregar",
|
||||
"edit": "Editar",
|
||||
"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": {
|
||||
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
|
||||
@@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"strings": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"emailAddress": "Adresse Email",
|
||||
"emailAddress": "Adresse mail",
|
||||
"name": "Nom",
|
||||
"submit": "Soumettre",
|
||||
"send": "Envoyer",
|
||||
@@ -29,9 +29,9 @@
|
||||
"logout": "Se déconnecter",
|
||||
"admin": "Administrateur",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactiver",
|
||||
"disabled": "Désactivé",
|
||||
"reEnable": "Ré-activé",
|
||||
"disable": "Désactivé",
|
||||
"disable": "Désactiver",
|
||||
"expiry": "Expiration",
|
||||
"add": "Ajouter",
|
||||
"edit": "Éditer",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"password": "Senha",
|
||||
"emailAddress": "Endereço de e-mail",
|
||||
"name": "Nome",
|
||||
"submit": "Enviar",
|
||||
"submit": "Envie",
|
||||
"send": "Enviar",
|
||||
"success": "Sucesso",
|
||||
"continue": "Continuar",
|
||||
@@ -36,7 +36,12 @@
|
||||
"add": "Adicionar",
|
||||
"edit": "Editar",
|
||||
"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": {
|
||||
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
|
||||
@@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"logout": "登出",
|
||||
"admin": "管理员",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"disabled": "禁用",
|
||||
"reEnable": "重新启用",
|
||||
"disable": "禁用",
|
||||
"expiry": "到期",
|
||||
@@ -40,7 +40,8 @@
|
||||
"contactMethods": "联系方式",
|
||||
"accountStatus": "帐户状态",
|
||||
"notSet": "未设置",
|
||||
"myAccount": "我的帐户"
|
||||
"myAccount": "我的帐户",
|
||||
"referrals": "推荐"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "用户名/密码留空。",
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
"title": "Your account has been re-enabled - Jellyfin",
|
||||
"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": {
|
||||
"name": "Invite email",
|
||||
"title": "Invite - Jellyfin",
|
||||
@@ -74,4 +81,4 @@
|
||||
"yourAccountHasExpired": "Your account has expired.",
|
||||
"contactTheAdmin": "Contact the administrator for more info."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,30 @@
|
||||
"confirmationRequired": "Se requiere confirmación por correo electrónico",
|
||||
"confirmationRequiredMessage": "Por favor, revise la bandeja de entrada de su correo electrónico para verificar su dirección.",
|
||||
"yourAccountIsValidUntil": "Su cuenta será válida hasta el {date}.",
|
||||
"sendPINDiscord": "Escribe {command} en {server_channel} en Discord, luego envía el PIN a través de DM al bot.",
|
||||
"sendPINDiscord": "Escriba {command} en {server_channel} en Discord, luego envíe el PIN a través de DM al bot.",
|
||||
"sendPIN": "Envíe el PIN a continuación al bot, luego regrese aquí para vincular su cuenta.",
|
||||
"matrixEnterUser": "Ingrese su ID de usuario, presione enviar y se le enviará un PIN. Ingrese aquí para continuar."
|
||||
"matrixEnterUser": "Ingrese su ID de usuario, presione enviar y se le enviará un PIN. Ingrese aquí para continuar.",
|
||||
"oldPassword": "Contraseña anterior",
|
||||
"newPassword": "Nueva contraseña",
|
||||
"addContactMethod": "Añadir método de contacto",
|
||||
"editContactMethod": "Editar método de contacto",
|
||||
"resetPassword": "Restablecer contraseña",
|
||||
"joinTheServer": "Unirse al servidor:",
|
||||
"resetPasswordThroughJellyfin": "Para restablecer la contraseña, visite {jfLink} y presione el botón \"Contraseña olvidada\".",
|
||||
"userPageSuccessMessage": "Puede ver y modificar los detalles de su cuenta más tarde desde la página de {myAccount}.",
|
||||
"resetPasswordThroughLink": "Para restablecer la contraseña, introduzca su nombre de usuario, dirección de correo electrónico o el nombre de usuario de un método de contacto vinculado, y presione enviar. Recibirá un enlace para restablecer la contraseña.",
|
||||
"resetSent": "Restablecimiento enviado.",
|
||||
"changePassword": "Cambiar contraseña",
|
||||
"copyReferral": "Copiar enlace",
|
||||
"invitedBy": "Invitación recibida de parte del usuario {user}.",
|
||||
"referralsDescription": "Invite a amigos y familiares a Jellyfin con este enlace. Vuelva aquí para conseguir uno nuevo si este caduca.",
|
||||
"resetPasswordThroughLinkStart": "Para restablecer su contraseña, introduzca uno de los siguientes a continuación:",
|
||||
"resetPasswordThroughLinkEnd": "Y presione enviar. Se le mandará un enlace para restablecer su contraseña.",
|
||||
"resetPasswordUsername": "Su nombre de usuario de Jellyfin",
|
||||
"resetPasswordEmail": "Su dirección de correo electrónico",
|
||||
"resetPasswordContactMethod": "El nombre de usuario de cualquier método de contacto vinculado a su cuenta",
|
||||
"resetSentDescription": "Si una cuenta con el nombre de usuario o método de contacto suministrados existe, se habrá enviado un enlace de restablecimiento de contraseña a través de todos los métodos de contacto disponibles. El código caducará a los 30 minutos.",
|
||||
"referralsWithExpiryDescription": "Invite a amigos y familia a Jellyfin con este enlace. El enlace se desactivará cuando caduque."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "El usuario ya existe.",
|
||||
@@ -35,7 +56,9 @@
|
||||
"errorPassword": "Requisitos para la contraseña.",
|
||||
"errorNoMatch": "Las contraseñas no coinciden.",
|
||||
"errorAccountLinked": "La cuenta ya está en uso.",
|
||||
"errorEmailLinked": "El correo electrónico ya está en uso."
|
||||
"errorEmailLinked": "El correo electrónico ya está en uso.",
|
||||
"errorOldPassword": "Contraseña anterior incorrecta.",
|
||||
"passwordChanged": "Contraseña cambiada."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
@@ -59,4 +82,4 @@
|
||||
"plural": "Debe tener al menos {n} caracteres especiales"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"pageTitle": "Créer un compte Jellyfin",
|
||||
"createAccountHeader": "Création du compte",
|
||||
"accountDetails": "Détails",
|
||||
"emailAddress": "Email",
|
||||
"emailAddress": "Mail",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"reEnterPassword": "Confirmez mot de passe",
|
||||
@@ -38,7 +38,13 @@
|
||||
"resetPassword": "Réinitialisation mot de passe",
|
||||
"referralsDescription": "Invitez vos amis et votre famille à Jellyfin avec ce lien. Revenez ici pour en obtenir un nouveau s'il expire.",
|
||||
"copyReferral": "Copier le lien",
|
||||
"invitedBy": "Vous avez été invité par l'utilisateur {user}."
|
||||
"invitedBy": "Vous avez été invité par l'utilisateur {user}.",
|
||||
"resetPasswordUsername": "Votre username Jellyfin",
|
||||
"resetPasswordEmail": "Votre adresse email",
|
||||
"resetPasswordThroughLinkStart": "Pour réinitialiser votre mot de passe, saisissez l'une des informations suivantes :",
|
||||
"resetPasswordContactMethod": "Le nom d'utilisateur de toute méthode de contact liée à votre compte",
|
||||
"resetPasswordThroughLinkEnd": "Appuyez ensuite sur Soumettre. Un lien vous sera envoyé pour réinitialiser votre mot de passe.",
|
||||
"referralsWithExpiryDescription": "Invitez vos amis et votre famille à Jellyfin avec ce lien. Le lien sera désactivé une fois expiré."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
|
||||
@@ -37,7 +37,13 @@
|
||||
"resetSent": "Reset-link verstuurd.",
|
||||
"referralsDescription": "Nodig vrienden en familie uit met deze link. Kom hier terug voor een nieuwe als hij verloopt.",
|
||||
"copyReferral": "Kopieer link",
|
||||
"invitedBy": "Je bent uitgenodigd door gebruiker {user}."
|
||||
"invitedBy": "Je bent uitgenodigd door gebruiker {user}.",
|
||||
"resetPasswordThroughLinkStart": "Voer één van onderstaande velden in om je wachtwoord te resetten:",
|
||||
"resetPasswordUsername": "Je Jellyfin gebruikersnaam",
|
||||
"referralsWithExpiryDescription": "Nodig vrienden en familie uit voor Jellyfin met deze link. De link wordt uitgeschakeld zodra hij verloopt.",
|
||||
"resetPasswordEmail": "Je e-mailadres",
|
||||
"resetPasswordThroughLinkEnd": "Druk daarna op versturen. Er wordt dan een link verstuurd om je wachtwoord te resetten.",
|
||||
"resetPasswordContactMethod": "De gebruikersnaam van een contactmethode die aan je account gekoppeld is"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Português (BR)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Criar Conta Jellyfin",
|
||||
"pageTitle": "Crie uma conta Jellyfin",
|
||||
"createAccountHeader": "Criar Conta",
|
||||
"accountDetails": "Detalhes",
|
||||
"emailAddress": "E-mail",
|
||||
@@ -17,9 +17,33 @@
|
||||
"confirmationRequired": "Confirmação por e-mail",
|
||||
"confirmationRequiredMessage": "Verifique sua caixa de email para finalizar o cadastro.",
|
||||
"yourAccountIsValidUntil": "Sua conta é válida até {date}.",
|
||||
"sendPIN": "Envie o PIN abaixo para o bot e volte aqui para vincular sua conta.",
|
||||
"sendPIN": "Envie o PIN abaixo para o bot e depois volte aqui para vincular sua conta.",
|
||||
"sendPINDiscord": "Digite {command} em {server_channel} no Discord e envie o PIN abaixo.",
|
||||
"matrixEnterUser": "Digite sua ID de usuário, pressione enviar e um PIN será enviado. E digite aqui para continuar."
|
||||
"matrixEnterUser": "Digite sua ID de usuário, pressione enviar e um PIN será enviado. E digite aqui para continuar.",
|
||||
"resetPasswordUsername": "Seu nome de usuário Jellyfin",
|
||||
"resetPasswordThroughLinkEnd": "Em seguida, pressione enviar. Um link será enviado para redefinir sua senha.",
|
||||
"resetPasswordEmail": "Seu endereço de email",
|
||||
"resetPasswordContactMethod": "O nome de usuário de qualquer método de contato vinculado à sua conta",
|
||||
"resetSentDescription": "Se existir uma conta com o nome de usuário/método de contato fornecido, um link de redefinição de senha será enviado por todos os métodos de contato disponíveis. O código expirará em 30 minutos.",
|
||||
"oldPassword": "Senha Antiga",
|
||||
"newPassword": "Nova Senha",
|
||||
"welcomeUser": "Bem-vindo, {usuário}!",
|
||||
"changePassword": "Alterar a senha",
|
||||
"addContactMethod": "Adicionar Método de Contato",
|
||||
"editContactMethod": "Editar Método de Contato",
|
||||
"joinTheServer": "Junte-se ao servidor:",
|
||||
"customMessagePlaceholderContent": "Clique no botão de edição da página do usuário nas configurações para personalizar este cartão ou mostre um na tela de login e não se preocupe, o usuário não poderá ver isso.",
|
||||
"customMessagePlaceholderHeader": "Personalize este cartão",
|
||||
"userPageSuccessMessage": "Você poderá ver e alterar os detalhes da sua conta posteriormente na página {minha conta}.",
|
||||
"resetPassword": "Redefinir senha",
|
||||
"resetPasswordThroughJellyfin": "Para redefinir sua senha, visite {jfLink} e pressione o botão \"Esqueci minha senha\".",
|
||||
"resetPasswordThroughLink": "Para redefinir sua senha, insira seu nome de usuário, endereço de e-mail ou um nome de usuário de método de contato vinculado e envie. Um link será enviado para redefinir sua senha.",
|
||||
"resetSent": "Redefinir enviado.",
|
||||
"referralsDescription": "Convide amigos e familiares para Jellyfin com este link. Volte aqui para comprar um novo se ele expirar.",
|
||||
"copyReferral": "Copiar link",
|
||||
"invitedBy": "Você foi convidado pelo usuário {user}.",
|
||||
"resetPasswordThroughLinkStart": "Para redefinir sua senha, digite um dos seguintes itens abaixo:",
|
||||
"referralsWithExpiryDescription": "Convide amigos e familiares para Jellyfin com este link. O link será desativado quando expirar."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "Esse usuário já existe.",
|
||||
@@ -36,7 +60,9 @@
|
||||
"errorPassword": "Verifique os requisitos de senha.",
|
||||
"errorNoMatch": "As senhas não coincidem.",
|
||||
"errorEmailLinked": "Este E-mail já está sendo utilizado.",
|
||||
"errorAccountLinked": "Esta conta já está sendo utilizada."
|
||||
"errorAccountLinked": "Esta conta já está sendo utilizada.",
|
||||
"errorOldPassword": "Senha antiga incorreta.",
|
||||
"passwordChanged": "Senha alterada."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
@@ -60,4 +86,4 @@
|
||||
"plural": "Deve ter pelo menos {n} caracteres especiais"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,16 @@
|
||||
"welcomeUser": "欢迎,{user}!",
|
||||
"editContactMethod": "修改联系方式",
|
||||
"joinTheServer": "加入服务器:",
|
||||
"customMessagePlaceholderHeader": "自定义此卡"
|
||||
"customMessagePlaceholderHeader": "自定义此卡",
|
||||
"referralsDescription": "使用此链接邀请朋友和家人加入Jellyfin。如果链接过期,请回到这里获取一个新的。",
|
||||
"copyReferral": "复制链接",
|
||||
"invitedBy": "您是由用户{user}邀请的。",
|
||||
"resetPasswordThroughLinkEnd": "按下提交按钮后,将会发送一个链接用以重置您的密码。",
|
||||
"resetPasswordUsername": "你的Jellyfin用户名",
|
||||
"resetPasswordThroughLinkStart": "要重置密码,请输入以下任一信息:",
|
||||
"resetPasswordEmail": "你的邮箱地址",
|
||||
"resetPasswordContactMethod": "与您的账户相关联的任何联系方式的用户名",
|
||||
"referralsWithExpiryDescription": "使用此链接邀请亲朋好友加入 Jellyfin。过期后,链接将失效。"
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "用户已经存在。",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"error404": "404, prüfe die interne URL.",
|
||||
"errorConnectionRefused": "Verbindung abgelehnt.",
|
||||
"error": "Fehler",
|
||||
"errorUnknown": "Unbekannter Fehler, prüfe die Logs."
|
||||
"errorUnknown": "Unbekannter Fehler, prüfe die Logs.",
|
||||
"errorProxy": "Proxy Konfiguration ungültig."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Willkommen!",
|
||||
@@ -63,7 +64,8 @@
|
||||
"adminOnly": "Nur Admin-Benutzer (empfohlen)",
|
||||
"emailNotice": "Deine E-Mail-Adresse kann verwendet werden, um Benachrichtigungen zu erhalten.",
|
||||
"allowAll": "Erlaube allen Jellyfin-Nutzern sich anzumelden",
|
||||
"allowAllDescription": "Nicht empfohlen. Nach der Einrichtung solltest du einzelnen Benutzern erlauben, sich anzumelden."
|
||||
"allowAllDescription": "Nicht empfohlen. Nach der Einrichtung solltest du einzelnen Benutzern erlauben, sich anzumelden.",
|
||||
"authorizeManualUserPageNotice": "Dadurch wird die Funktion \"Benutzerseite\" deaktiviert."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -148,5 +150,11 @@
|
||||
"messages": {
|
||||
"title": "Mitteilungen",
|
||||
"description": "jfa-go kann Passwortrücksetzungen und verschiedene Benachrichtigungen per E-Mail, Discord, Telegram und/oder Matrix verschicken. E-Mail kannst du unten einrichten, die Anderen später in den Einstellungen. Anweisungen findest du im {n}. Falls nicht benötigt, kannst du diese Funktionen hier deaktivieren."
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Lass jfa-go alle Verbindungen über einen HTTP/SOCKS5-Proxy herstellen. Die Verbindung zu Jellyfin wird über diesen Proxy getestet.",
|
||||
"title": "Proxy",
|
||||
"protocol": "Protokoll",
|
||||
"address": "Adresse (inkl. Port)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Español (ES)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "instalación - jfa-go",
|
||||
"pageTitle": "Instalación - jfa-go",
|
||||
"next": "Siguiente",
|
||||
"back": "Volver",
|
||||
"optional": "Opcional",
|
||||
@@ -22,7 +22,8 @@
|
||||
"error404": "404, verifique la URL interna.",
|
||||
"errorConnectionRefused": "Conexión rechazada.",
|
||||
"errorUnknown": "Error desconocido, verifique los registros de la aplicación.",
|
||||
"error": "Error"
|
||||
"error": "Error",
|
||||
"errorProxy": "Configuración de proxy no válida."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "¡Bienvenido!",
|
||||
@@ -32,12 +33,12 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "¡Terminado!",
|
||||
"restartMessage": "Puede configurar los bots de Discord/Telegram/Matrix, personalizar sus mensajes y más en Configuración. Haga clic a continuación para reiniciar, luego actualice la página.",
|
||||
"refreshPage": "Actualizar"
|
||||
"restartMessage": "Funciones como bots de Discord/Telegram/Matrix, mensajes Markdown personalizados y una página de \"Mi cuenta\" accesible por el usuario pueden encontrarse en Ajustes, así que asegúrese de echarle un vistazo. Haga clic a continuación para reiniciar, y luego actualice la página.",
|
||||
"refreshPage": "Recargar"
|
||||
},
|
||||
"language": {
|
||||
"title": "Lenguaje",
|
||||
"description": "Las traducciones de la comunidad están disponibles para la mayor parte de jfa-go. Puede elegir los idiomas predeterminados a continuación, pero los usuarios aún pueden cambiarlo si lo desean. Si quieres ayudar a traducir, regístrate en {n} para empezar a contribuir!",
|
||||
"title": "Idioma",
|
||||
"description": "Las traducciones de la comunidad están disponibles para la mayor parte de jfa-go. Puede elegir los idiomas predeterminados a continuación, pero los usuarios aún pueden cambiarlo si lo desean. Si quieres ayudar a traducir, ¡regístrate en {n} para empezar a contribuir!",
|
||||
"defaultAdminLang": "Idioma de administrador predeterminado",
|
||||
"defaultFormLang": "Idioma de creación de cuenta predeterminado",
|
||||
"defaultEmailLang": "Idioma de correo electrónico predeterminado"
|
||||
@@ -70,7 +71,8 @@
|
||||
"adminOnly": "Solo usuarios administradores (recomendado)",
|
||||
"emailNotice": "Su dirección de correo electrónico se puede utilizar para recibir notificaciones.",
|
||||
"allowAll": "Permitir el acceso a todos los usuarios de Jellyfin",
|
||||
"allowAllDescription": "No se recomienda, debe permitir que los usuarios individuales se conecten una vez configurados."
|
||||
"allowAllDescription": "No se recomienda, debe permitir que los usuarios individuales se conecten una vez configurados.",
|
||||
"authorizeManualUserPageNotice": "Utilizar esto deshabilitará la función de \"Página de usuario\"."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -115,14 +117,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Restablecimiento de contraseña",
|
||||
"description": "Cuando un usuario intenta restablecer su contraseña, Jellyfin crea un archivo llamado 'passwordreset - *. Json' que contiene un PIN. jfa-go lee el archivo y envía el PIN al usuario.",
|
||||
"description": "Cuando un usuario intenta restablecer su contraseña, Jellyfin crea un archivo llamado 'passwordreset -*.json' que contiene un PIN. jfa-go lee el archivo y envía el PIN al usuario. Si activó la función de \"Página de usuario\", también se puede realizar un restablecimiento desde ahí, proporcionando un nombre de usuario, correo electrónico, o método de contacto.",
|
||||
"pathToJellyfin": "Ruta al directorio de configuración de Jellyfin",
|
||||
"pathToJellyfinNotice": "Si no sabe dónde está, intente restablecer su contraseña en Jellyfin. Aparecerá una ventana emergente con '<ruta a jellyfin>/passwordreset-. Json'.",
|
||||
"pathToJellyfinNotice": "Si no sabe dónde está, intente restablecer su contraseña en Jellyfin. Aparecerá una ventana emergente con '<ruta a jellyfin>/passwordreset-*.json'. Esto no es necesario si solo quiere usar el restablecimiento de contraseña de autoservicio a través de la \"Página de usuario\".",
|
||||
"resetLinks": "Envía un enlace en lugar de un PIN",
|
||||
"resetLinksNotice": "Si la integración de Ombi está habilitada, utilícela para sincronizar los restablecimientos de contraseña de Jellyfin con Ombi.",
|
||||
"resetLinksLanguage": "Enlace de restablecimiento predeterminado",
|
||||
"setPassword": "Establecer contraseña a través del enlace",
|
||||
"setPasswordNotice": "Habilitar esto significa que el usuario no tiene que cambiar su contraseña del PIN después del restablecimiento. También se aplicará la validación de la contraseña."
|
||||
"setPasswordNotice": "Habilitar esto significa que el usuario no tiene que cambiar su contraseña del PIN después del restablecimiento. También se aplicará la validación de la contraseña.",
|
||||
"resetLinksRequiredForUserPage": "Obligatorio para restablecimiento de contraseña de autoservicio en la Página de usuario."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Validación de contraseña",
|
||||
@@ -148,5 +151,17 @@
|
||||
"messages": {
|
||||
"description": "jfa-go puede enviar restablecimientos de contraseña y varios mensajes a través de correo electrónico, Discord, Telegram y/o Matrix. Puede configurar el correo electrónico a continuación y los demás se pueden configurar en Configuración más adelante. Las instrucciones se pueden encontrar en el {n}. Si no lo necesita, puede desactivar estas funciones aquí.",
|
||||
"title": "Mensajes"
|
||||
},
|
||||
"userPage": {
|
||||
"title": "Página de usuario",
|
||||
"customizeMessages": "Clique en el botón de editar al lado de \"Página de usuario\" en ajustes para establecerlos más tarde.",
|
||||
"description": "La página de usuario (mostrada como \"Mi cuenta\") permite a los usuarios acceder a información sobre su cuenta, como por ejemplo sus métodos de contacto y caducidad de la cuenta. También pueden cambiar su contraseña, iniciar un restablecimiento de contraseña, y vincular o cambiar métodos de contacto, sin tener que recurrir a usted. Además, se pueden enseñar mensajes personalizados de Markdown a los usuarios antes y después de que inicien sesión.",
|
||||
"requiredSettings": "Debe establecerse un inicio de sesión en jfa-go a través de Jellyfin. Asegúrese de que \"Restablecer contraseña a través de enlace\" está seleccionado para un restablecimiento de contraseña de autoservicio."
|
||||
},
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"description": "Hacer que jfa-go realice todas las conexiones a través de un proxy HTTP/SOCK5. La conexión a Jellyfin será evaluada a través de este.",
|
||||
"protocol": "Protocolo",
|
||||
"address": "Dirección (incluido el puerto)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"name": "Français (FR)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Installation - JFA-Go",
|
||||
"pageTitle": "Installation - jfa-go",
|
||||
"next": "Suivant",
|
||||
"back": "Retour",
|
||||
"optional": "Optionnel",
|
||||
"serverType": "Type de serveur",
|
||||
"disabled": "Désactiver",
|
||||
"disabled": "Désactivé",
|
||||
"enabled": "Activé",
|
||||
"port": "Port",
|
||||
"message": "Message",
|
||||
@@ -22,7 +22,8 @@
|
||||
"errorNotAdmin": "L'utilisateur n'est pas autorisé à gérer le serveur.",
|
||||
"errorConnectionRefused": "Connexion refusée.",
|
||||
"error": "Erreur",
|
||||
"errorUnknown": "Erreur inconnue, vérifier les logs de l'application."
|
||||
"errorUnknown": "Erreur inconnue, vérifier les logs de l'application.",
|
||||
"errorProxy": "Configuration du proxy invalide."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Bienvenue !",
|
||||
@@ -32,7 +33,7 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Terminé !",
|
||||
"restartMessage": "Vous pouvez configurer les bots Discord/Telegram/Matrix, personnaliser vos messages et plus encore dans Paramètres. Cliquez ci-dessous pour redémarrer, puis actualisez la page.",
|
||||
"restartMessage": "Vous pouvez configurer les bots Discord/Telegram/Matrix, personnaliser l’apparence vos messages, et plus encore dans Paramètres. Cliquez ci-dessous pour redémarrer, puis actualisez la page.",
|
||||
"refreshPage": "Rafraichir"
|
||||
},
|
||||
"language": {
|
||||
@@ -63,7 +64,8 @@
|
||||
"adminOnly": "Administrateurs seulement (recommandé)",
|
||||
"emailNotice": "Votre adresse e-mail peut être utilisée pour recevoir des notifications.",
|
||||
"allowAll": "Autoriser tous les utilisateurs de Jellyfin à se connecter",
|
||||
"allowAllDescription": "Non recommandé, vous devez autoriser individuellement les utilisateurs à se connecter une fois la configuration effectuée."
|
||||
"allowAllDescription": "Non recommandé, vous devez autoriser individuellement les utilisateurs à se connecter une fois la configuration effectuée.",
|
||||
"authorizeManualUserPageNotice": "Son utilisation désactivera la fonctionnalité \"Page utilisateur\"."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -108,14 +110,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Réinitialisation de mot de passe",
|
||||
"description": "Lorsqu'un utilisateur essaie de réinitialiser son mot de passe, Jellyfin créé un fichier nommé 'passwordreset-*.json' qui contient le code PIN. jfa-go lit le fichier et envoie le code PIN à l'utilisateur.",
|
||||
"description": "Lorsqu'un utilisateur essaie de réinitialiser son mot de passe, Jellyfin créé un fichier nommé 'passwordreset-*.json' qui contient le code PIN. jfa-go lit le fichier et envoie le code PIN à l'utilisateur. Si vous avez activé la fonctionnalité « Page utilisateur », une réinitialisation peut également y être effectuée, en fonction d'un nom d'utilisateur, d'un e-mail ou d'une méthode de contact.",
|
||||
"pathToJellyfin": "Chemin du dossier de configuration de Jellyfin",
|
||||
"pathToJellyfinNotice": "Si vous ne savez pas où c'est, essayez de réinitialiser votre mot de passe dans Jellyfin. Une popup avec '<path to jellyfin>/passwordreset-*.json' apparaitra.",
|
||||
"pathToJellyfinNotice": "Si vous ne savez pas où c'est, essayez de réinitialiser votre mot de passe dans Jellyfin. Une popup avec '<path to jellyfin>/passwordreset-*.json' apparaitra. Cela n'est pas nécessaire si vous souhaitez uniquement utiliser la réinitialisation de mot de passe en libre-service via la « Page utilisateur ».",
|
||||
"resetLinks": "Envoyer un lien plutôt qu'un PIN",
|
||||
"resetLinksNotice": "Si l'intégration est activée, utilisez ceci pour synchroniser les réinitialisations de mots de passe Jellyfin avec Ombi.",
|
||||
"resetLinksLanguage": "Langue du lien de réinitialisation par défaut",
|
||||
"setPassword": "Définir le mot de passe via le lien",
|
||||
"setPasswordNotice": "L'activation de cette option signifie que l'utilisateur n'a pas à modifier son mot de passe à partir du code PIN après la réinitialisation. La validation du mot de passe sera également appliquée."
|
||||
"setPasswordNotice": "L'activation de cette option signifie que l'utilisateur n'a pas à modifier son mot de passe à partir du code PIN après la réinitialisation. La validation du mot de passe sera également appliquée.",
|
||||
"resetLinksRequiredForUserPage": "Requis pour la réinitialisation du mot de passe par l'utilisateur sur la page utilisateur."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Validation du mot de passe",
|
||||
@@ -148,5 +151,17 @@
|
||||
"messages": {
|
||||
"title": "Messages",
|
||||
"description": "jfa-go peut envoyer des réinitialisations de mot de passe et divers messages par e-mail, Discord, Telegram et/ou Matrix. Vous pouvez configurer l'e-mail ci-dessous, et les autres peuvent être configurés dans les paramètres plus tard. Les instructions se trouvent sur le {n}. Si vous n'en avez pas besoin, vous pouvez désactiver ces fonctionnalités ici."
|
||||
},
|
||||
"userPage": {
|
||||
"title": "Page utilisateur",
|
||||
"customizeMessages": "Cliquez sur le bouton Modifier à côté de « Page utilisateur » dans les paramètres pour les définir ultérieurement.",
|
||||
"requiredSettings": "La connexion à jfa-go via Jellyfin doit être définie. Assurez-vous que « Réinitialiser le mot de passe via un lien » est sélectionné ultérieurement pour les réinitialisations de mot de passe par les utilisateurs.",
|
||||
"description": "La page utilisateur (affichée sous « Mon compte ») permet aux utilisateurs d'accéder aux informations sur leur compte, telles que leurs méthodes de contact et l'expiration de leur compte. Ils peuvent également modifier leur mot de passe, lancer une réinitialisation de mot de passe et lier/modifier les méthodes de contact, sans avoir à vous le demander. De plus, des messages personnalisés peuvent être affichés aux utilisateurs avant et après la connexion."
|
||||
},
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"protocol": "Protocole",
|
||||
"address": "Adresse (y compris le port)",
|
||||
"description": "Demandez à jfa-go d'établir toutes les connexions via un proxy HTTP/SOCKS5. La connexion à Jellyfin sera testée à travers cela."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
|
||||
"errorConnectionRefused": "Verbinding geweigerd.",
|
||||
"errorUnknown": "Onbekende fout, bekijk de logs.",
|
||||
"error": "Fout"
|
||||
"error": "Fout",
|
||||
"errorProxy": "Proxy-instellingen onjuist."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Welkom!",
|
||||
@@ -156,5 +157,11 @@
|
||||
"title": "Gebruikerspagina",
|
||||
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
|
||||
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
|
||||
},
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"description": "Laat jfa-go alle verbindingen via een HTTP/SOCKS5 proxy maken. De verbinding met Jellyfin wordt hierdoorheen getest.",
|
||||
"protocol": "Protocol",
|
||||
"address": "Adres (inclusief poort)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"name": "Português (BR)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Configuração - jfa-go",
|
||||
"pageTitle": "Configuração do jfa-go",
|
||||
"next": "Próximo",
|
||||
"back": "Voltar",
|
||||
"optional": "Opcional",
|
||||
"serverType": "Tipo de Servidor",
|
||||
"disabled": "Desativado",
|
||||
"disabled": "Desabilitado",
|
||||
"enabled": "Habilitado",
|
||||
"port": "Porta",
|
||||
"message": "Mensagem",
|
||||
@@ -20,7 +20,10 @@
|
||||
"errorUserDisabled": "O usuário pode estar desabilitado.",
|
||||
"error404": "404, verifique a URL interna.",
|
||||
"errorInvalidUserPass": "Usuário ou Senha Inválidos.",
|
||||
"errorConnectionRefused": "Conexão recusada."
|
||||
"errorConnectionRefused": "Conexão recusada.",
|
||||
"errorUnknown": "Erro desconhecido. Verifique os registros do aplicativo.",
|
||||
"errorProxy": "Configuração de proxy inválida.",
|
||||
"error": "Error"
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Bem Vindo!",
|
||||
@@ -61,7 +64,8 @@
|
||||
"adminOnly": "Apenas usuários administradores (recomendado)",
|
||||
"emailNotice": "Seu endereço de email pode ser usado para receber notificações.",
|
||||
"allowAll": "Permitir que todos os usuários do Jellyfin façam login",
|
||||
"allowAllDescription": "Não recomendado, você deve permitir que usuários individuais façam login após a configuração."
|
||||
"allowAllDescription": "Não recomendado, você deve permitir que usuários individuais façam login após a configuração.",
|
||||
"authorizeManualUserPageNotice": "Usar isso desativará o recurso \"Página do usuário\"."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -113,7 +117,8 @@
|
||||
"resetLinksNotice": "Se a integração do Ombi estiver habilitada, use para sincronizar as redefinições de senha do Jellyfin com o Ombi.",
|
||||
"resetLinksLanguage": "Idioma do link de redefinição padrão",
|
||||
"setPassword": "Definir a senha por meio de link",
|
||||
"setPasswordNotice": "Se habilitar significa que o usuário não precisa alterar a senha do PIN após a redefinição. A validação de senha também será aplicada."
|
||||
"setPasswordNotice": "Se habilitar significa que o usuário não precisa alterar a senha do PIN após a redefinição. A validação de senha também será aplicada.",
|
||||
"resetLinksRequiredForUserPage": "Obrigatório para redefinição de senha de autoatendimento na página do usuário."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Validar Senha",
|
||||
@@ -125,7 +130,7 @@
|
||||
"special": "Caracteres Especial (%, *, etc.)"
|
||||
},
|
||||
"helpMessages": {
|
||||
"title": "Mensagem de Ajuda",
|
||||
"title": "Mensagem para Ajuda",
|
||||
"description": "Essas mensagens serão exibidas na página de criação de conta e em alguns emails.",
|
||||
"contactMessage": "Mensagem de Contato",
|
||||
"contactMessageNotice": "Exibido na parte inferior de todas as páginas, exceto na administradora.",
|
||||
@@ -146,5 +151,17 @@
|
||||
"messages": {
|
||||
"title": "Mensagens",
|
||||
"description": "jfa-go pode enviar redefinições de senha e várias mensagens por e-mail, Discord, Telegram e/ou Matrix. Você pode configurar o e-mail abaixo e os outros podem ser configurados em Configurações posteriormente. As instruções podem ser encontradas em {n}. Se você não precisar, pode desativar esses recursos aqui."
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Faça com que o jfa-go faça todas as conexões por meio de um proxy HTTP/SOCKS5. A conexão com o Jellyfin será testada através disso.",
|
||||
"title": "Proxy",
|
||||
"protocol": "Procedimento",
|
||||
"address": "Endereço (incluindo porta)"
|
||||
},
|
||||
"userPage": {
|
||||
"description": "A página do usuário (mostrada como “Minha Conta”) permite que os usuários acessem informações sobre sua conta, como métodos de contato e vencimento da conta. Eles também podem alterar sua senha, iniciar uma redefinição de senha e vincular/alterar métodos de contato, sem precisar perguntar a você. Além disso, mensagens Markdown personalizadas podem ser mostradas aos usuários antes e depois do entrar.",
|
||||
"title": "Página de usuário",
|
||||
"customizeMessages": "Clique no botão de edição próximo a “Página do usuário” nas configurações para defini-las mais tarde.",
|
||||
"requiredSettings": "Ao entra no jfa-go via Jellyfin deve ser configurado. Certifique-se de que \"redefinir senha via link\" esteja selecionado posteriormente para redefinições de senha de autoatendimento."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"back": "上一步",
|
||||
"optional": "可选的",
|
||||
"serverType": "服务类型",
|
||||
"disabled": "已禁用",
|
||||
"disabled": "禁用",
|
||||
"enabled": "已启用",
|
||||
"port": "端口",
|
||||
"message": "信息",
|
||||
@@ -17,12 +17,13 @@
|
||||
"URL": "链接",
|
||||
"apiKey": "API 密钥",
|
||||
"errorInvalidUserPass": "无效的用户名/密码。",
|
||||
"errorNotAdmin": "此用户不允许管理服务。",
|
||||
"errorNotAdmin": "用户没有权限管理服务器。",
|
||||
"errorUserDisabled": "此永固可能已被禁用。",
|
||||
"error404": "404,请检查内部URL。",
|
||||
"errorConnectionRefused": "连接被拒绝。",
|
||||
"error": "错误",
|
||||
"errorUnknown": "未知错误,请检查应用程序日志。"
|
||||
"errorUnknown": "未知错误,请检查应用程序日志。",
|
||||
"errorProxy": "代理配置无效。"
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "欢迎!",
|
||||
@@ -70,7 +71,8 @@
|
||||
"adminOnly": "仅允许管理员账户(推荐)",
|
||||
"emailNotice": "您的电子邮件地址可以用来接收通知。",
|
||||
"allowAllDescription": "不建议,您应该允许单个用户在设置后登录。",
|
||||
"allowAll": "允许所有Jellyfin用户登录"
|
||||
"allowAll": "允许所有Jellyfin用户登录",
|
||||
"authorizeManualUserPageNotice": "使用此选项将禁用“用户页面”功能。"
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -126,7 +128,8 @@
|
||||
"resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。",
|
||||
"resetLinksLanguage": "默认重置链接语言",
|
||||
"setPassword": "通过链接设置密码",
|
||||
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。"
|
||||
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。",
|
||||
"resetLinksRequiredForUserPage": "对于用户页面上的自助密码重置,这是必需的。"
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "密码验证",
|
||||
@@ -148,5 +151,17 @@
|
||||
"successMessageNotice": "在用户创建账户时显示。",
|
||||
"emailMessage": "电子邮件",
|
||||
"emailMessageNotice": "显示在电子邮件的底部。"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "让jfa-go通过HTTP/SOCKS5代理进行所有连接。连接到Jellyfin将通过此代理进行测试。",
|
||||
"title": "代理",
|
||||
"protocol": "协议",
|
||||
"address": "地址(包括端口)"
|
||||
},
|
||||
"userPage": {
|
||||
"description": "用户页面(显示为“我的帐户”)允许用户访问有关他们帐户的信息,如其联系方式和账户过期日期。他们还可以更改密码、启动密码重置,并在无需询问您的情况下链接/更改联系方式。此外,用户还可以在登录前后看到自定义的Markdown消息。",
|
||||
"title": "用户页面",
|
||||
"customizeMessages": "在设置中,单击“用户页面”旁边的编辑按钮以稍后进行设置。",
|
||||
"requiredSettings": "通过Jellyfin登录到jfa-go必须设置。确保稍后选择“通过链接重置密码”以进行自助密码重置。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"invalidPIN": "Ce code PIN est invalide, réessayez.",
|
||||
"pinSuccess": "Succès ! Vous pouvez maintenant retourner à la page d’inscription.",
|
||||
"languageMessage": "Note : Découvrez les langues disponibles avec {command} et paramétrez la langue souhaitée avec {command} <language code>.",
|
||||
"discordStartMessage": "Salut !\nEntrez votre code PIN avec `/pin <PIN>` pour vérifier votre compte.",
|
||||
"discordStartMessage": "Salut !\n.Entrez votre code PIN avec `/pin <PIN>` pour vérifier votre compte.",
|
||||
"discordDMs": "Veuillez vérifier vos DM pour une réponse.",
|
||||
"languageSet": "Langue définie sur {language}.",
|
||||
"languageMessageDiscord": "Note : définissez votre langue avec /lang <nom de la langue>."
|
||||
"languageMessageDiscord": "Note : définissez votre langue avec /lang <nom de la langue>.",
|
||||
"sentInvite": "Invitation envoyée.",
|
||||
"sentInviteFailure": "Échec de l'envoi de l'invitation, vérifiez les journaux."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"languageMessageDiscord": "Nota: defina seu idioma com /lang <language name>.",
|
||||
"languageSet": "Idioma definido como {idioma}.",
|
||||
"discordDMs": "Por favor, verifique seus DMs para uma resposta.",
|
||||
"discordStartMessage": "Oi!\n Digite seu PIN com `/pin <PIN>` para verificar sua conta."
|
||||
"discordStartMessage": "Oi!\n Digite seu PIN com `/pin <PIN>` para verificar sua conta.",
|
||||
"sentInvite": "Enviou convite.",
|
||||
"sentInviteFailure": "Falha ao enviar o convite. Verifique os registros."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"discordStartMessage": "您好!\n请输入 `/pin <PIN码>`以验证您的账户。",
|
||||
"languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。",
|
||||
"languageSet": "语言改成 {language}。",
|
||||
"discordDMs": "请检查您的DM找回答。"
|
||||
"discordDMs": "请检查您的DM找回答。",
|
||||
"sentInvite": "已发送邀请。",
|
||||
"sentInviteFailure": "发送邀请失败,请检查日志。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
83
mail/expiry-adjusted.mjml
Normal file
83
mail/expiry-adjusted.mjml
Normal file
@@ -0,0 +1,83 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .helloUser }}</p>
|
||||
|
||||
<h3>{{ .yourExpiryWasAdjusted }}</h3>
|
||||
|
||||
<p>{{ .ifPreviouslyDisabled }}</p>
|
||||
|
||||
<h4>{{ .newExpiry }}</h4>
|
||||
|
||||
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
11
mail/expiry-adjusted.txt
Normal file
11
mail/expiry-adjusted.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
{{ .helloUser }}
|
||||
|
||||
{{ .yourExpiryWasAdjusted }}
|
||||
|
||||
{{ .ifPreviouslyDisabled }}
|
||||
|
||||
{{ .newExpiry }}
|
||||
|
||||
{{ .reasonString }}: {{ .reason }}
|
||||
|
||||
{{ .message }}
|
||||
55
main.go
55
main.go
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
_ "github.com/hrfee/jfa-go/docs"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
"github.com/hrfee/jfa-go/ombi"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
@@ -46,6 +47,8 @@ var (
|
||||
SWAGGER *bool
|
||||
QUIT = false
|
||||
RUNNING = false
|
||||
LOGIP = false // Log admin IPs
|
||||
LOGIPU = false // Log user IPs
|
||||
// Used to know how many times to re-broadcast restart signal.
|
||||
RESTARTLISTENERCOUNT = 0
|
||||
warning = color.New(color.FgYellow).SprintfFunc()
|
||||
@@ -99,6 +102,7 @@ type appContext struct {
|
||||
jf *mediabrowser.MediaBrowser
|
||||
authJf *mediabrowser.MediaBrowser
|
||||
ombi *ombi.Ombi
|
||||
js *jellyseerr.Jellyseerr
|
||||
datePattern string
|
||||
timePattern string
|
||||
storage Storage
|
||||
@@ -120,6 +124,7 @@ type appContext struct {
|
||||
proxyTransport *http.Transport
|
||||
proxyConfig easyproxy.ProxyConfig
|
||||
internalPWRs map[string]InternalPWR
|
||||
pwrCaptchas map[string]Captcha
|
||||
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
|
||||
confirmationKeysLock sync.Mutex
|
||||
}
|
||||
@@ -356,6 +361,19 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
}
|
||||
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
app.debug.Printf("Connecting to Jellyseerr")
|
||||
jellyseerrServer := app.config.Section("jellyseerr").Key("server").String()
|
||||
app.js = jellyseerr.NewJellyseerr(
|
||||
jellyseerrServer,
|
||||
app.config.Section("jellyseerr").Key("api_key").String(),
|
||||
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
|
||||
)
|
||||
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
|
||||
// app.js.LogRequestBodies = true
|
||||
|
||||
}
|
||||
|
||||
app.storage.db_path = filepath.Join(app.dataPath, "db")
|
||||
app.loadPendingBackup()
|
||||
app.ConnectDB()
|
||||
@@ -463,13 +481,21 @@ func start(asDaemon, firstCall bool) {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
invDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
|
||||
invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app)
|
||||
go invDaemon.run()
|
||||
defer invDaemon.Shutdown()
|
||||
|
||||
userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
|
||||
go userDaemon.run()
|
||||
defer userDaemon.shutdown()
|
||||
defer userDaemon.Shutdown()
|
||||
|
||||
var jellyseerrDaemon *GenericDaemon
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) {
|
||||
// jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app)
|
||||
jellyseerrDaemon = newJellyseerrDaemon(time.Duration(10*time.Minute), app)
|
||||
go jellyseerrDaemon.run()
|
||||
defer jellyseerrDaemon.Shutdown()
|
||||
}
|
||||
|
||||
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
|
||||
go app.StartPWR()
|
||||
@@ -479,7 +505,7 @@ func start(asDaemon, firstCall bool) {
|
||||
go app.checkForUpdates()
|
||||
}
|
||||
|
||||
var backupDaemon *housekeepingDaemon
|
||||
var backupDaemon *GenericDaemon
|
||||
if app.config.Section("backups").Key("enabled").MustBool(false) {
|
||||
backupDaemon = newBackupDaemon(app)
|
||||
go backupDaemon.run()
|
||||
@@ -554,11 +580,28 @@ func start(asDaemon, firstCall bool) {
|
||||
cert := app.config.Section("advanced").Key("tls_cert").MustString("")
|
||||
key := app.config.Section("advanced").Key("tls_key").MustString("")
|
||||
if err := SRV.ListenAndServeTLS(cert, key); err != nil {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
filesToCheck := []string{cert, key}
|
||||
fileNames := []string{"Certificate", "Key"}
|
||||
for i, v := range filesToCheck {
|
||||
_, err := os.Stat(v)
|
||||
if err != nil {
|
||||
app.err.Printf("SSL/TLS %s: %v\n", fileNames[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == http.ErrServerClosed {
|
||||
app.err.Printf("Failure serving with SSL/TLS: %s", err)
|
||||
} else {
|
||||
app.err.Fatalf("Failure serving with SSL/TLS: %s", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := SRV.ListenAndServe(); err != nil {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
if err == http.ErrServerClosed {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
} else {
|
||||
app.err.Fatalf("Failure serving: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -616,7 +659,7 @@ func flagPassed(name string) (found bool) {
|
||||
}
|
||||
|
||||
// @title jfa-go internal API
|
||||
// @version 0.4.0
|
||||
// @version 0.5.1
|
||||
// @description API for the jfa-go frontend
|
||||
// @contact.name Harvey Tindall
|
||||
// @contact.email hrfee@hrfee.dev
|
||||
|
||||
@@ -17,6 +17,7 @@ func runMigrations(app *appContext) {
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
// migrateHyphens(app)
|
||||
migrateToBadger(app)
|
||||
intialiseCustomContent(app)
|
||||
}
|
||||
|
||||
// Migrate pre-0.2.0 user templates to profiles
|
||||
@@ -329,6 +330,8 @@ func migrateToBadger(app *appContext) {
|
||||
app.storage.SetCustomContentKey("UserPage", app.storage.deprecatedUserPageContent.Page)
|
||||
}
|
||||
|
||||
// Custom content not present here was added post-badger.
|
||||
|
||||
err := app.storage.db.Upsert("migrated_to_db", MigrationStatus{true})
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to migrate to DB: %v\n", err)
|
||||
@@ -336,6 +339,57 @@ func migrateToBadger(app *appContext) {
|
||||
app.info.Println("All data migrated to database. JSON files in the config folder can be deleted if you are sure all data is correct in the app. Create an issue if you have problems.")
|
||||
}
|
||||
|
||||
// Simply creates an emply CC template if not in the DB already.
|
||||
// Add new CC types here!
|
||||
func intialiseCustomContent(app *appContext) {
|
||||
emptyCC := CustomContent{
|
||||
Enabled: false,
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserCreated"); !ok {
|
||||
app.storage.SetCustomContentKey("UserCreated", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("InviteExpiry"); !ok {
|
||||
app.storage.SetCustomContentKey("InviteExpiry", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("PasswordReset"); !ok {
|
||||
app.storage.SetCustomContentKey("PasswordReset", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserDeleted"); !ok {
|
||||
app.storage.SetCustomContentKey("UserDeleted", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserDisabled"); !ok {
|
||||
app.storage.SetCustomContentKey("UserDisabled", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserEnabled"); !ok {
|
||||
app.storage.SetCustomContentKey("UserEnabled", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("InviteEmail"); !ok {
|
||||
app.storage.SetCustomContentKey("InviteEmail", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("WelcomeEmail"); !ok {
|
||||
app.storage.SetCustomContentKey("WelcomeEmail", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("EmailConfirmation"); !ok {
|
||||
app.storage.SetCustomContentKey("EmailConfirmation", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserExpired"); !ok {
|
||||
app.storage.SetCustomContentKey("UserExpired", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserLogin"); !ok {
|
||||
app.storage.SetCustomContentKey("UserLogin", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserPage"); !ok {
|
||||
app.storage.SetCustomContentKey("UserPage", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok {
|
||||
app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC)
|
||||
}
|
||||
if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok {
|
||||
app.storage.SetCustomContentKey("PostSignupCard", emptyCC)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate between hyphenated & non-hyphenated user IDs. Doesn't seem to happen anymore, so disabled.
|
||||
// func migrateHyphens(app *appContext) {
|
||||
// checkVersion := func(version string) int {
|
||||
|
||||
42
models.go
42
models.go
@@ -16,6 +16,7 @@ type newUserDTO struct {
|
||||
Username string `json:"username" example:"jeff" binding:"required"` // User's username
|
||||
Password string `json:"password" example:"guest" binding:"required"` // User's password
|
||||
Email string `json:"email" example:"jeff@jellyf.in"` // User's email address
|
||||
EmailContact bool `json:"email_contact"` // Whether or not to use email for notifications/pwrs
|
||||
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
|
||||
TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used)
|
||||
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
|
||||
@@ -76,6 +77,7 @@ type profileDTO struct {
|
||||
LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to
|
||||
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
|
||||
Ombi bool `json:"ombi"` // Whether or not Ombi settings are stored in this profile.
|
||||
Jellyseerr bool `json:"jellyseerr"` // Whether or not Jellyseerr settings are stored in this profile.
|
||||
ReferralsEnabled bool `json:"referrals_enabled" example:"true"` // Whether or not the profile has referrals enabled, and has a template invite stored.
|
||||
}
|
||||
|
||||
@@ -172,11 +174,16 @@ type ombiUsersDTO struct {
|
||||
type modifyEmailsDTO map[string]string
|
||||
|
||||
type userSettingsDTO struct {
|
||||
From string `json:"from"` // Whether to apply from "user" or "profile"
|
||||
Profile string `json:"profile"` // Name of profile (if from = "profile")
|
||||
ApplyTo []string `json:"apply_to"` // Users to apply settings to
|
||||
ID string `json:"id"` // ID of user (if from = "user")
|
||||
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
|
||||
From string `json:"from"` // Whether to apply from "user" or "profile"
|
||||
Profile string `json:"profile"` // Name of profile (if from = "profile")
|
||||
ApplyTo []string `json:"apply_to"` // Users to apply settings to
|
||||
ID string `json:"id"` // ID of user (if from = "user")
|
||||
// Note confusing name: "Configuration" on the admin UI just means it in the sense
|
||||
// of the account's settings.
|
||||
Policy bool `json:"configuration"` // Whether to apply jf policy not
|
||||
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
|
||||
Ombi bool `json:"ombi"` // Whether to apply ombi profile or not
|
||||
Jellyseerr bool `json:"jellyseerr"` // Whether to apply jellyseerr profile or not
|
||||
}
|
||||
|
||||
type announcementDTO struct {
|
||||
@@ -239,8 +246,9 @@ type langDTO map[string]string
|
||||
type emailListDTO map[string]emailListEl
|
||||
|
||||
type emailListEl struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type emailSetDTO struct {
|
||||
@@ -261,12 +269,14 @@ type customEmailDTO struct {
|
||||
}
|
||||
|
||||
type extendExpiryDTO struct {
|
||||
Users []string `json:"users"` // List of user IDs to apply to.
|
||||
Months int `json:"months" example:"1"` // Number of months to add.
|
||||
Days int `json:"days" example:"1"` // Number of days to add.
|
||||
Hours int `json:"hours" example:"2"` // Number of hours to add.
|
||||
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
|
||||
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
|
||||
Users []string `json:"users"` // List of user IDs to apply to.
|
||||
Months int `json:"months" example:"1"` // Number of months to add.
|
||||
Days int `json:"days" example:"1"` // Number of days to add.
|
||||
Hours int `json:"hours" example:"2"` // Number of hours to add.
|
||||
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
|
||||
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
|
||||
Notify bool `json:"notify"` // Whether to message the user(s) about the change.
|
||||
Reason string `json:"reason" example:"i felt like it"` // Reason for adjustment.
|
||||
}
|
||||
|
||||
type checkUpdateDTO struct {
|
||||
@@ -332,8 +342,9 @@ type MatrixLoginDTO struct {
|
||||
}
|
||||
|
||||
type ResetPasswordDTO struct {
|
||||
PIN string `json:"pin"`
|
||||
Password string `json:"password"`
|
||||
PIN string `json:"pin"`
|
||||
Password string `json:"password"`
|
||||
CaptchaText string `json:"captcha_text"`
|
||||
}
|
||||
|
||||
type AdminPasswordResetDTO struct {
|
||||
@@ -444,6 +455,7 @@ type ActivityDTO struct {
|
||||
InviteCode string `json:"invite_code"`
|
||||
Value string `json:"value"`
|
||||
Time int64 `json:"time"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type GetActivitiesDTO struct {
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -1653,6 +1653,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
||||
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
|
||||
29
router.go
29
router.go
@@ -24,19 +24,29 @@ func (app *appContext) loadHTML(router *gin.Engine) {
|
||||
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
|
||||
return
|
||||
}
|
||||
loadFiles := make([]string, len(htmlFiles))
|
||||
for i, f := range htmlFiles {
|
||||
loadInternal := []string{}
|
||||
loadExternal := []string{}
|
||||
for _, f := range htmlFiles {
|
||||
if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) {
|
||||
app.debug.Printf("Using default \"%s\"", f.Name())
|
||||
loadFiles[i] = FSJoin(templatePath, f.Name())
|
||||
loadInternal = append(loadInternal, FSJoin(templatePath, f.Name()))
|
||||
} else {
|
||||
app.info.Printf("Using custom \"%s\"", f.Name())
|
||||
loadFiles[i] = filepath.Join(filepath.Join(customPath, f.Name()))
|
||||
loadExternal = append(loadExternal, filepath.Join(filepath.Join(customPath, f.Name())))
|
||||
}
|
||||
}
|
||||
tmpl, err := template.ParseFS(localFS, loadFiles...)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to load templates: %v", err)
|
||||
var tmpl *template.Template
|
||||
if len(loadInternal) != 0 {
|
||||
tmpl, err = template.ParseFS(localFS, loadInternal...)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
}
|
||||
if len(loadExternal) != 0 {
|
||||
tmpl, err = tmpl.ParseFiles(loadExternal...)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to load external templates: %v", err)
|
||||
}
|
||||
}
|
||||
router.SetHTMLTemplate(tmpl)
|
||||
}
|
||||
@@ -228,6 +238,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
api.GET(p+"/users/discord/:username", app.DiscordGetUsers)
|
||||
api.POST(p+"/users/discord", app.DiscordConnect)
|
||||
}
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
api.GET(p+"/jellyseerr/users", app.JellyseerrUsers)
|
||||
api.POST(p+"/profiles/jellyseerr/:profile/:id", app.SetJellyseerrProfile)
|
||||
api.DELETE(p+"/profiles/jellyseerr/:profile", app.DeleteJellyseerrProfile)
|
||||
}
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
api.GET(p+"/ombi/users", app.OmbiUsers)
|
||||
api.POST(p+"/profiles/ombi/:profile", app.SetOmbiProfile)
|
||||
|
||||
@@ -16,4 +16,6 @@ else
|
||||
fi
|
||||
|
||||
JFA_GO_VERSION=$(git describe --exact-match HEAD 2> /dev/null || echo 'vgit')
|
||||
JFA_GO_CSS_VERSION="v3" JFA_GO_NFPM_EPOCH=$(git rev-list --all --count) JFA_GO_BUILD_TIME=$(date +%s) JFA_GO_BUILT_BY=${JFA_GO_BUILT_BY:-"???"} JFA_GO_VERSION="$(echo $JFA_GO_VERSION | sed 's/v//g')" $@
|
||||
TIMEOUT=60m
|
||||
|
||||
JFA_GO_CSS_VERSION="v3" JFA_GO_NFPM_EPOCH=$(git rev-list --all --count) JFA_GO_BUILD_TIME=$(date +%s) JFA_GO_BUILT_BY=${JFA_GO_BUILT_BY:-"???"} JFA_GO_VERSION="$(echo $JFA_GO_VERSION | sed 's/v//g')" $@ --timeout $TIMEOUT
|
||||
|
||||
@@ -7,11 +7,11 @@ all:
|
||||
cp -r ts tempts
|
||||
../scripts/dark-variant.sh tempts
|
||||
npx esbuild --target=es6 --bundle tempts/main.ts --outfile=out/main.js --minify
|
||||
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --minify
|
||||
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --external:../fonts/hanken* --minify
|
||||
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
|
||||
cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd ..
|
||||
mv out/_remixicon.css out/remixicon.css
|
||||
cp ../static/* out/
|
||||
cp -r ../static/* out/
|
||||
node inject.js
|
||||
|
||||
debug:
|
||||
@@ -22,10 +22,10 @@ debug:
|
||||
-rm -r tempts
|
||||
cp -r ts tempts
|
||||
../scripts/dark-variant.sh tempts
|
||||
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --minify
|
||||
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
|
||||
npx esbuild --target=es6 --bundle ts/main.ts --sourcemap --outfile=out/main.js --minify
|
||||
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
|
||||
cp ../static/* out/
|
||||
cp -r ../static/* out/
|
||||
|
||||
monitor:
|
||||
npx live-server --watch=out --open=out/index.html &
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
|
||||
<span class="heading"> Debian/Ubuntu (apt)</span>
|
||||
<div class="mt-1">
|
||||
<pre style="margin: 0; line-height: 125%">curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
|
||||
<pre style="margin: 0; line-height: 125%">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
|
||||
|
||||
echo <span style="color: #aa5500">"deb https://apt.hrfee.dev trusty<span id="deb-unstable" class="unfocused">-unstable</span> main"</span> | sudo tee /etc/apt/sources.list.d/hrfee.list
|
||||
|
||||
@@ -129,7 +130,7 @@ sudo apt-get install jfa-go-tray
|
||||
<a class="button ~info mr-2 mb-2 lang-link" target="_blank" href="https://aur.archlinux.org/packages/jfa-go-git">arch (aur git)</a>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE" class="support">© 2023 Harvey Tindall</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
39
storage.go
39
storage.go
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
@@ -55,6 +57,7 @@ type Activity struct {
|
||||
InviteCode string // Set for ActivityCreation, create/deleteInvite
|
||||
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
|
||||
Time time.Time
|
||||
IP string
|
||||
}
|
||||
|
||||
type UserExpiry struct {
|
||||
@@ -563,8 +566,12 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) {
|
||||
}
|
||||
|
||||
// SetActivityKey stores value v in key k.
|
||||
func (st *Storage) SetActivityKey(k string, v Activity) {
|
||||
// If the IP should be logged, pass "gc", and whether or not the action is of a user
|
||||
func (st *Storage) SetActivityKey(k string, v Activity, gc *gin.Context, user bool) {
|
||||
v.ID = k
|
||||
if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) {
|
||||
v.IP = gc.ClientIP()
|
||||
}
|
||||
err := st.db.Upsert(k, v)
|
||||
if err != nil {
|
||||
// fmt.Printf("Failed to set custom content: %v\n", err)
|
||||
@@ -604,16 +611,17 @@ type EmailAddress struct {
|
||||
}
|
||||
|
||||
type customEmails struct {
|
||||
UserCreated CustomContent `json:"userCreated"`
|
||||
InviteExpiry CustomContent `json:"inviteExpiry"`
|
||||
PasswordReset CustomContent `json:"passwordReset"`
|
||||
UserDeleted CustomContent `json:"userDeleted"`
|
||||
UserDisabled CustomContent `json:"userDisabled"`
|
||||
UserEnabled CustomContent `json:"userEnabled"`
|
||||
InviteEmail CustomContent `json:"inviteEmail"`
|
||||
WelcomeEmail CustomContent `json:"welcomeEmail"`
|
||||
EmailConfirmation CustomContent `json:"emailConfirmation"`
|
||||
UserExpired CustomContent `json:"userExpired"`
|
||||
UserCreated CustomContent `json:"userCreated"`
|
||||
InviteExpiry CustomContent `json:"inviteExpiry"`
|
||||
PasswordReset CustomContent `json:"passwordReset"`
|
||||
UserDeleted CustomContent `json:"userDeleted"`
|
||||
UserDisabled CustomContent `json:"userDisabled"`
|
||||
UserEnabled CustomContent `json:"userEnabled"`
|
||||
UserExpiryAdjusted CustomContent `json:"userExpiryAdjusted"`
|
||||
InviteEmail CustomContent `json:"inviteEmail"`
|
||||
WelcomeEmail CustomContent `json:"welcomeEmail"`
|
||||
EmailConfirmation CustomContent `json:"emailConfirmation"`
|
||||
UserExpired CustomContent `json:"userExpired"`
|
||||
}
|
||||
|
||||
// CustomContent stores customized versions of jfa-go content, including emails and user messages.
|
||||
@@ -643,9 +651,16 @@ type Profile struct {
|
||||
Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
Ombi map[string]interface{} `json:"ombi,omitempty"`
|
||||
Jellyseerr JellyseerrTemplate `json:"jellyseerr,omitempty"`
|
||||
ReferralTemplateKey string
|
||||
}
|
||||
|
||||
type JellyseerrTemplate struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
User jellyseerr.UserTemplate `json:"user,omitempty"`
|
||||
Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
Code string `badgerhold:"key"`
|
||||
Created time.Time `json:"created"`
|
||||
@@ -1219,6 +1234,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
|
||||
patchLang(&lang.UserDeleted, &fallback.UserDeleted, &english.UserDeleted)
|
||||
patchLang(&lang.UserDisabled, &fallback.UserDisabled, &english.UserDisabled)
|
||||
patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled)
|
||||
patchLang(&lang.UserExpiryAdjusted, &fallback.UserExpiryAdjusted, &english.UserExpiryAdjusted)
|
||||
patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail)
|
||||
patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail)
|
||||
patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation)
|
||||
@@ -1233,6 +1249,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
|
||||
patchLang(&lang.UserDeleted, &english.UserDeleted)
|
||||
patchLang(&lang.UserDisabled, &english.UserDisabled)
|
||||
patchLang(&lang.UserEnabled, &english.UserEnabled)
|
||||
patchLang(&lang.UserExpiryAdjusted, &english.UserExpiryAdjusted)
|
||||
patchLang(&lang.InviteEmail, &english.InviteEmail)
|
||||
patchLang(&lang.WelcomeEmail, &english.WelcomeEmail)
|
||||
patchLang(&lang.EmailConfirmation, &english.EmailConfirmation)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { accountsList } from "./modules/accounts.js";
|
||||
import { settingsList } from "./modules/settings.js";
|
||||
import { activityList } from "./modules/activity.js";
|
||||
import { ProfileEditor } from "./modules/profiles.js";
|
||||
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
|
||||
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
|
||||
import { Updater } from "./modules/update.js";
|
||||
import { Login } from "./modules/login.js";
|
||||
|
||||
@@ -49,6 +49,9 @@ window.availableProfiles = window.availableProfiles || [];
|
||||
|
||||
window.modals.ombiProfile = new Modal(document.getElementById('modal-ombi-profile'));
|
||||
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiProfile.close);
|
||||
|
||||
window.modals.jellyseerrProfile = new Modal(document.getElementById('modal-jellyseerr-profile'));
|
||||
document.getElementById('form-jellyseerr-defaults').addEventListener('submit', window.modals.jellyseerrProfile.close);
|
||||
|
||||
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
|
||||
|
||||
@@ -184,6 +187,7 @@ login.onLogin = () => {
|
||||
console.log("Logged in.");
|
||||
window.updater = new Updater();
|
||||
// FIXME: Decide whether to autoload activity or not
|
||||
window.invites.reload()
|
||||
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
|
||||
const currentTab = window.tabs.current;
|
||||
switch (currentTab) {
|
||||
@@ -216,6 +220,8 @@ login.onLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
bindManualDropdowns();
|
||||
|
||||
login.bindLogout(document.getElementById("logout-button"));
|
||||
|
||||
login.login("", "");
|
||||
|
||||
86
ts/form.ts
86
ts/form.ts
@@ -4,6 +4,7 @@ import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from
|
||||
import { loadLangSelector } from "./modules/lang.js";
|
||||
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
||||
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
|
||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||
|
||||
interface formWindow extends Window {
|
||||
invalidPassword: string;
|
||||
@@ -37,6 +38,7 @@ interface formWindow extends Window {
|
||||
reCAPTCHASiteKey: string;
|
||||
userPageEnabled: boolean;
|
||||
userPageAddress: string;
|
||||
customSuccessCard: boolean;
|
||||
}
|
||||
|
||||
loadLangSelector("form");
|
||||
@@ -172,35 +174,7 @@ if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameFie
|
||||
const passwordField = document.getElementById("create-password") as HTMLInputElement;
|
||||
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
|
||||
|
||||
let captchaVerified = false;
|
||||
let captchaID = "";
|
||||
let captchaInput = document.getElementById("captcha-input") as HTMLInputElement;
|
||||
const captchaCheckbox = document.getElementById("captcha-success") as HTMLSpanElement;
|
||||
let prevCaptcha = "";
|
||||
|
||||
let baseValidator = (oncomplete: (valid: boolean) => void): void => {
|
||||
if (window.captcha && !window.reCAPTCHA && (captchaInput.value != prevCaptcha)) {
|
||||
prevCaptcha = captchaInput.value;
|
||||
_post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status == 204) {
|
||||
captchaCheckbox.innerHTML = `<i class="ri-check-line"></i>`;
|
||||
captchaCheckbox.classList.add("~positive");
|
||||
captchaCheckbox.classList.remove("~critical");
|
||||
captchaVerified = true;
|
||||
} else {
|
||||
captchaCheckbox.innerHTML = `<i class="ri-close-line"></i>`;
|
||||
captchaCheckbox.classList.add("~critical");
|
||||
captchaCheckbox.classList.remove("~positive");
|
||||
captchaVerified = false;
|
||||
}
|
||||
_baseValidator(oncomplete, captchaVerified);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_baseValidator(oncomplete, captchaVerified);
|
||||
}
|
||||
}
|
||||
let captcha = new Captcha(window.code, window.captcha, window.reCAPTCHA, false);
|
||||
|
||||
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
|
||||
if (window.emailRequired) {
|
||||
@@ -228,20 +202,9 @@ function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: bool
|
||||
oncomplete(true);
|
||||
}
|
||||
|
||||
interface GreCAPTCHA {
|
||||
render: (container: HTMLDivElement, parameters: {
|
||||
sitekey?: string,
|
||||
theme?: string,
|
||||
size?: string,
|
||||
tabindex?: number,
|
||||
"callback"?: () => void,
|
||||
"expired-callback"?: () => void,
|
||||
"error-callback"?: () => void
|
||||
}) => void;
|
||||
getResponse: (opt_widget_id?: HTMLDivElement) => string;
|
||||
}
|
||||
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
|
||||
|
||||
declare var grecaptcha: GreCAPTCHA
|
||||
declare var grecaptcha: GreCAPTCHA;
|
||||
|
||||
let validatorConf: ValidatorConf = {
|
||||
passwordField: passwordField,
|
||||
@@ -261,6 +224,7 @@ if (window.emailRequired) {
|
||||
interface sendDTO {
|
||||
code: string;
|
||||
email: string;
|
||||
email_contact?: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
telegram_pin?: string;
|
||||
@@ -273,29 +237,15 @@ interface sendDTO {
|
||||
captcha_text?: string;
|
||||
}
|
||||
|
||||
const genCaptcha = () => {
|
||||
_get("/captcha/gen/"+window.code, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status == 200) {
|
||||
captchaID = req.response["id"];
|
||||
document.getElementById("captcha-img").innerHTML = `
|
||||
<img class="w-100" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${window.code}/${captchaID}"></img>
|
||||
`;
|
||||
captchaInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (window.captcha && !window.reCAPTCHA) {
|
||||
genCaptcha();
|
||||
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
|
||||
captchaInput.onkeyup = validator.validate;
|
||||
captcha.generate();
|
||||
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
|
||||
captcha.input.onkeyup = validator.validate;
|
||||
}
|
||||
|
||||
const create = (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
if (window.captcha && !window.reCAPTCHA && !captchaVerified) {
|
||||
if (window.captcha && !window.reCAPTCHA && !captcha.verified) {
|
||||
|
||||
}
|
||||
addLoader(submitSpan);
|
||||
@@ -303,8 +253,9 @@ const create = (event: SubmitEvent) => {
|
||||
code: window.code,
|
||||
username: usernameField.value,
|
||||
email: emailField.value,
|
||||
email_contact: true,
|
||||
password: passwordField.value
|
||||
};
|
||||
}
|
||||
if (telegramVerified) {
|
||||
send.telegram_pin = window.telegramPIN;
|
||||
const checkbox = document.getElementById("contact-via-telegram") as HTMLInputElement;
|
||||
@@ -326,12 +277,16 @@ const create = (event: SubmitEvent) => {
|
||||
send.matrix_contact = true;
|
||||
}
|
||||
}
|
||||
if (matrixVerified || discordVerified || telegramVerified) {
|
||||
const checkbox = document.getElementById("contact-via-email") as HTMLInputElement;
|
||||
send.email_contact = checkbox.checked;
|
||||
}
|
||||
if (window.captcha) {
|
||||
if (window.reCAPTCHA) {
|
||||
send.captcha_text = grecaptcha.getResponse();
|
||||
} else {
|
||||
send.captcha_id = captchaID;
|
||||
send.captcha_text = captchaInput.value;
|
||||
send.captcha_id = captcha.captchaID;
|
||||
send.captcha_text = captcha.input.value;
|
||||
}
|
||||
}
|
||||
_post("/newUser", send, (req: XMLHttpRequest) => {
|
||||
@@ -348,7 +303,10 @@ const create = (event: SubmitEvent) => {
|
||||
const url = ((document.getElementById("modal-success") as HTMLDivElement).querySelector("a.submit") as HTMLAnchorElement).href;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
if (window.userPageEnabled) {
|
||||
if (window.customSuccessCard) {
|
||||
const content = window.successModal.asElement().querySelector(".card");
|
||||
content.innerHTML = content.innerHTML.replace(new RegExp("{username}", "g"), send.username)
|
||||
} else if (window.userPageEnabled) {
|
||||
const userPageNoticeArea = document.getElementById("modal-success-user-page-area");
|
||||
const link = `<a href="${window.userPageAddress}" target="_blank">${userPageNoticeArea.getAttribute("my-account-term")}</a>`;
|
||||
userPageNoticeArea.innerHTML = userPageNoticeArea.textContent.replace("{myAccount}", link);
|
||||
|
||||
@@ -188,7 +188,7 @@ class user implements User, SearchableItem {
|
||||
if (!telegram && !discord && !matrix && !email) return;
|
||||
let innerHTML = `
|
||||
<i class="icon ri-settings-2-line ml-2 dropdown-button"></i>
|
||||
<div class="dropdown over-top manual">
|
||||
<div class="dropdown manual">
|
||||
<div class="dropdown-display lg">
|
||||
<div class="card ~neutral @low">
|
||||
<div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div>
|
||||
@@ -218,13 +218,13 @@ class user implements User, SearchableItem {
|
||||
</div>
|
||||
<div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div>
|
||||
<div class="accounts-unlink-telegram">
|
||||
<button class="button ~critical mb-2 w-100">Telegram</button>
|
||||
<button class="button ~critical mb-2 w-full">Telegram</button>
|
||||
</div>
|
||||
<div class="accounts-unlink-discord">
|
||||
<button class="button ~critical mb-2 w-100">Discord</button>
|
||||
<button class="button ~critical mb-2 w-full">Discord</button>
|
||||
</div>
|
||||
<div class="accounts-unlink-matrix">
|
||||
<button class="button ~critical mb-2 w-100">Matrix</button>
|
||||
<button class="button ~critical mb-2 w-full">Matrix</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -795,6 +795,11 @@ export class accountsList {
|
||||
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _search: Search;
|
||||
|
||||
private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement;
|
||||
private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement;
|
||||
private _applyOmbi = document.getElementById("modify-user-ombi") as HTMLInputElement;
|
||||
private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement;
|
||||
|
||||
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
|
||||
private _users: { [id: string]: user };
|
||||
private _ordering: string[] = [];
|
||||
@@ -1271,12 +1276,13 @@ export class accountsList {
|
||||
dList.textContent = '';
|
||||
for (let name of list) {
|
||||
const el = document.createElement("div") as HTMLDivElement;
|
||||
el.classList.add("flex-expand", "ellipsis", "mt-2");
|
||||
el.classList.add("flex", "flex-row", "justify-between", "truncate", "mt-2");
|
||||
el.innerHTML = `
|
||||
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-4 accounts-announce-template-delete">×</span>
|
||||
`;
|
||||
let urlSafeName = encodeURIComponent(encodeURIComponent(name));
|
||||
(el.querySelector("span.accounts-announce-template-button") as HTMLSpanElement).onclick = () => {
|
||||
_get("/users/announce/" + name, null, (req: XMLHttpRequest) => {
|
||||
_get("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
let template: announcementTemplate;
|
||||
if (req.status != 200) {
|
||||
@@ -1289,7 +1295,7 @@ export class accountsList {
|
||||
});
|
||||
};
|
||||
(el.querySelector("span.accounts-announce-template-delete") as HTMLSpanElement).onclick = () => {
|
||||
_delete("/users/announce/" + name, null, (req: XMLHttpRequest) => {
|
||||
_delete("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("deleteTemplateError", window.lang.notif("errorFailureCheckLogs"));
|
||||
@@ -1458,6 +1464,7 @@ export class accountsList {
|
||||
const modalHeader = document.getElementById("header-modify-user");
|
||||
modalHeader.textContent = window.lang.quantity("modifySettingsFor", this._collectUsers().length)
|
||||
let list = this._collectUsers();
|
||||
|
||||
(() => {
|
||||
let innerHTML = "";
|
||||
for (const profile of window.availableProfiles) {
|
||||
@@ -1476,6 +1483,7 @@ export class accountsList {
|
||||
|
||||
const form = document.getElementById("form-modify-user") as HTMLFormElement;
|
||||
const button = form.querySelector("span.submit") as HTMLSpanElement;
|
||||
|
||||
this._modifySettingsProfile.checked = true;
|
||||
this._modifySettingsUser.checked = false;
|
||||
form.onsubmit = (event: Event) => {
|
||||
@@ -1483,7 +1491,10 @@ export class accountsList {
|
||||
toggleLoader(button);
|
||||
let send = {
|
||||
"apply_to": list,
|
||||
"homescreen": (document.getElementById("modify-user-homescreen") as HTMLInputElement).checked
|
||||
"homescreen": this._applyHomescreen.checked,
|
||||
"configuration": this._applyConfiguration.checked,
|
||||
"ombi": this._applyOmbi.checked,
|
||||
"jellyseerr": this._applyJellyseerr.checked
|
||||
};
|
||||
if (this._modifySettingsProfile.checked && !this._modifySettingsUser.checked) {
|
||||
send["from"] = "profile";
|
||||
@@ -1634,6 +1645,7 @@ export class accountsList {
|
||||
_displayExpiryDate = () => {
|
||||
let date: Date;
|
||||
let invalid = false;
|
||||
let users = this._collectUsers();
|
||||
if (this._usingExtendExpiryTextInput) {
|
||||
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
|
||||
invalid = "invalid" in (date as any);
|
||||
@@ -1645,7 +1657,7 @@ export class accountsList {
|
||||
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
|
||||
];
|
||||
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
|
||||
let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
|
||||
let id = users.length > 0 ? users[0] : "";
|
||||
if (!id) invalid = true;
|
||||
else {
|
||||
date = new Date(this._users[id].expiry*1000);
|
||||
@@ -1665,7 +1677,12 @@ export class accountsList {
|
||||
} else {
|
||||
submit.disabled = false;
|
||||
submitSpan.classList.remove("opacity-60");
|
||||
this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
|
||||
this._extendExpiryDate.innerHTML = `
|
||||
<div class="flex flex-col">
|
||||
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
|
||||
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
|
||||
</div>
|
||||
`;
|
||||
this._extendExpiryDate.classList.remove("unfocused");
|
||||
}
|
||||
}
|
||||
@@ -1677,22 +1694,25 @@ export class accountsList {
|
||||
applyList.push(id);
|
||||
}
|
||||
this._enableExpiryReason.classList.add("unfocused");
|
||||
this._enableExpiryNotify.parentElement.classList.remove("unfocused");
|
||||
this._enableExpiryNotify.checked = false;
|
||||
this._enableExpiryReason.value = "";
|
||||
let header: string;
|
||||
if (enableUser) {
|
||||
header = window.lang.quantity("reEnableUsers", list.length);
|
||||
this._enableExpiryNotify.parentElement.classList.remove("unfocused");
|
||||
this._enableExpiryNotify.checked = false;
|
||||
this._enableExpiryReason.value = "";
|
||||
} else if (this._settingExpiry) {
|
||||
header = window.lang.quantity("setExpiry", list.length);
|
||||
this._enableExpiryNotify.parentElement.classList.add("unfocused");
|
||||
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
|
||||
} else {
|
||||
header = window.lang.quantity("extendExpiry", applyList.length);
|
||||
this._enableExpiryNotify.parentElement.classList.add("unfocused");
|
||||
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
|
||||
}
|
||||
document.getElementById("header-extend-expiry").textContent = header;
|
||||
const extend = () => {
|
||||
let send = { "users": applyList, "timestamp": 0 }
|
||||
let send = { "users": applyList, "timestamp": 0, "notify": this._enableExpiryNotify.checked }
|
||||
if (this._enableExpiryNotify.checked) {
|
||||
send["reason"] = this._enableExpiryReason.value;
|
||||
}
|
||||
if (this._usingExtendExpiryTextInput) {
|
||||
let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
|
||||
send["timestamp"] = Math.floor(date.getTime() / 1000);
|
||||
@@ -1721,7 +1741,7 @@ export class accountsList {
|
||||
this._extendExpiryForm.onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (enableUser) {
|
||||
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
|
||||
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify.checked ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status != 200 && req.status != 204) {
|
||||
window.modals.extendExpiry.close();
|
||||
@@ -1740,6 +1760,9 @@ export class accountsList {
|
||||
}
|
||||
}
|
||||
this._extendExpiryTextInput.value = "";
|
||||
this._usingExtendExpiryTextInput = false;
|
||||
this._extendExpiryDate.classList.add("unfocused");
|
||||
this._displayExpiryDate();
|
||||
window.modals.extendExpiry.show();
|
||||
}
|
||||
|
||||
@@ -1808,6 +1831,16 @@ export class accountsList {
|
||||
};
|
||||
this._modifySettings.onclick = this.modifyUsers;
|
||||
this._modifySettings.classList.add("unfocused");
|
||||
|
||||
if (window.ombiEnabled)
|
||||
this._applyOmbi.parentElement.classList.remove("unfocused");
|
||||
else
|
||||
this._applyOmbi.parentElement.classList.add("unfocused");
|
||||
if (window.jellyseerrEnabled)
|
||||
this._applyJellyseerr.parentElement.classList.remove("unfocused");
|
||||
else
|
||||
this._applyJellyseerr.parentElement.classList.add("unfocused");
|
||||
|
||||
const checkSource = () => {
|
||||
const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
|
||||
const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
|
||||
@@ -1818,6 +1851,8 @@ export class accountsList {
|
||||
profileSpan.classList.remove("@low");
|
||||
userSpan.classList.remove("@high");
|
||||
userSpan.classList.add("@low");
|
||||
this._applyOmbi.parentElement.classList.remove("unfocused");
|
||||
this._applyJellyseerr.parentElement.classList.remove("unfocused");
|
||||
} else {
|
||||
this._userSelect.parentElement.classList.remove("unfocused");
|
||||
this._profileSelect.parentElement.classList.add("unfocused");
|
||||
@@ -1825,6 +1860,8 @@ export class accountsList {
|
||||
userSpan.classList.remove("@low");
|
||||
profileSpan.classList.remove("@high");
|
||||
profileSpan.classList.add("@low");
|
||||
this._applyOmbi.parentElement.classList.add("unfocused");
|
||||
this._applyJellyseerr.parentElement.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
this._modifySettingsProfile.onchange = checkSource;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface activity {
|
||||
time: number;
|
||||
username: string;
|
||||
source_username: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
var activityTypeMoods = {
|
||||
@@ -43,6 +44,7 @@ export class Activity implements activity, SearchableItem {
|
||||
private _referrer: HTMLElement;
|
||||
private _expiryTypeBadge: HTMLElement;
|
||||
private _delete: HTMLElement;
|
||||
private _ip: HTMLElement;
|
||||
private _act: activity;
|
||||
private _urlBase: string = ((): string => {
|
||||
let link = window.location.href;
|
||||
@@ -205,6 +207,16 @@ export class Activity implements activity, SearchableItem {
|
||||
}
|
||||
}
|
||||
|
||||
get ip(): string { return this._act.ip; }
|
||||
set ip(v: string) {
|
||||
this._act.ip = v;
|
||||
if (v) {
|
||||
this._ip.innerHTML = `<span class="supra mr-2">IP</span><span class="font-mono bg-inherit">${v}</span>`;
|
||||
} else {
|
||||
this._ip.textContent = ``;
|
||||
}
|
||||
}
|
||||
|
||||
get invite_code(): string { return this._act.invite_code; }
|
||||
set invite_code(v: string) {
|
||||
this._act.invite_code = v;
|
||||
@@ -260,12 +272,13 @@ export class Activity implements activity, SearchableItem {
|
||||
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between">
|
||||
<div>
|
||||
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-row justify-between items-end">
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<div>
|
||||
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
|
||||
</div>
|
||||
<span class="content activity-referrer"></span>
|
||||
<span class="content activity-ip"></span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
|
||||
@@ -277,6 +290,7 @@ export class Activity implements activity, SearchableItem {
|
||||
this._time = this._card.querySelector(".activity-time");
|
||||
this._sourceType = this._card.querySelector(".activity-source-type");
|
||||
this._source = this._card.querySelector(".activity-source");
|
||||
this._ip = this._card.querySelector(".activity-ip");
|
||||
this._referrer = this._card.querySelector(".activity-referrer");
|
||||
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
|
||||
this._delete = this._card.querySelector(".activity-delete");
|
||||
@@ -324,6 +338,7 @@ export class Activity implements activity, SearchableItem {
|
||||
this.source = act.source;
|
||||
this.value = act.value;
|
||||
this.type = act.type;
|
||||
this.ip = act.ip;
|
||||
}
|
||||
|
||||
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {
|
||||
|
||||
81
ts/modules/captcha.ts
Normal file
81
ts/modules/captcha.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { _get, _post } from "./common.js";
|
||||
|
||||
export class Captcha {
|
||||
isPWR = false;
|
||||
enabled = true;
|
||||
verified = false;
|
||||
captchaID = "";
|
||||
input = document.getElementById("captcha-input") as HTMLInputElement;
|
||||
checkbox = document.getElementById("captcha-success") as HTMLSpanElement;
|
||||
previous = "";
|
||||
reCAPTCHA = false;
|
||||
code = "";
|
||||
|
||||
get value(): string { return this.input.value; }
|
||||
|
||||
hasChanged = (): boolean => { return this.value != this.previous; }
|
||||
|
||||
baseValidatorWrapper = (_baseValidator: (oncomplete: (valid: boolean) => void, captchaValid: boolean) => void) => {
|
||||
return (oncomplete: (valid: boolean) => void): void => {
|
||||
if (this.enabled && !this.reCAPTCHA && this.hasChanged()) {
|
||||
this.previous = this.value;
|
||||
this.verify(() => {
|
||||
_baseValidator(oncomplete, this.verified);
|
||||
});
|
||||
} else {
|
||||
_baseValidator(oncomplete, this.verified);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
verify = (callback: () => void) => _post("/captcha/verify/" + this.code + "/" + this.captchaID + "/" + this.input.value + (this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status == 204) {
|
||||
this.checkbox.innerHTML = `<i class="ri-check-line"></i>`;
|
||||
this.checkbox.classList.add("~positive");
|
||||
this.checkbox.classList.remove("~critical");
|
||||
this.verified = true;
|
||||
} else {
|
||||
this.checkbox.innerHTML = `<i class="ri-close-line"></i>`;
|
||||
this.checkbox.classList.add("~critical");
|
||||
this.checkbox.classList.remove("~positive");
|
||||
this.verified = false;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
generate = () => _get("/captcha/gen/"+this.code+(this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status == 200) {
|
||||
this.captchaID = this.isPWR ? this.code : req.response["id"];
|
||||
// the Math.random() appearance below is used for PWRs, since they don't have a unique captchaID. The parameter is ignored by the server, but tells the browser to reload the image.
|
||||
document.getElementById("captcha-img").innerHTML = `
|
||||
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
|
||||
`;
|
||||
this.input.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
constructor(code: string, enabled: boolean, reCAPTCHA: boolean, isPWR: boolean) {
|
||||
this.code = code;
|
||||
this.enabled = enabled;
|
||||
this.reCAPTCHA = reCAPTCHA;
|
||||
this.isPWR = isPWR;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GreCAPTCHA {
|
||||
render: (container: HTMLDivElement, parameters: {
|
||||
sitekey?: string,
|
||||
theme?: string,
|
||||
size?: string,
|
||||
tabindex?: number,
|
||||
"callback"?: () => void,
|
||||
"expired-callback"?: () => void,
|
||||
"error-callback"?: () => void
|
||||
}) => void;
|
||||
getResponse: (opt_widget_id?: HTMLDivElement) => string;
|
||||
}
|
||||
|
||||
@@ -263,3 +263,31 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function bindManualDropdowns() {
|
||||
const buttons = Array.from(document.getElementsByClassName("dropdown-manual-toggle") as HTMLCollectionOf<HTMLSpanElement>);
|
||||
for (let button of buttons) {
|
||||
const parent = button.closest(".dropdown.manual");
|
||||
const display = parent.querySelector(".dropdown-display");
|
||||
const mousein = () => parent.classList.add("selected");
|
||||
const mouseout = () => parent.classList.remove("selected");
|
||||
button.addEventListener("mouseover", mousein);
|
||||
button.addEventListener("mouseout", mouseout);
|
||||
display.addEventListener("mouseover", mousein);
|
||||
display.addEventListener("mouseout", mouseout);
|
||||
button.onclick = () => {
|
||||
parent.classList.add("selected");
|
||||
document.addEventListener("click", outerClickListener);
|
||||
button.removeEventListener("mouseout", mouseout);
|
||||
display.removeEventListener("mouseout", mouseout);
|
||||
};
|
||||
const outerClickListener = (event: Event) => {
|
||||
if (!(event.target instanceof HTMLElement && (display.contains(event.target) || button.contains(event.target)))) {
|
||||
parent.classList.remove("selected");
|
||||
document.removeEventListener("click", outerClickListener);
|
||||
button.addEventListener("mouseout", mouseout);
|
||||
display.addEventListener("mouseout", mouseout);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class DOMInvite implements Invite {
|
||||
get expiresIn(): string { return this._expiresIn }
|
||||
set expiresIn(expiry: string) {
|
||||
this._expiresIn = expiry;
|
||||
this._infoArea.querySelector("span.inv-duration").textContent = expiry;
|
||||
this._codeArea.querySelector("span.inv-duration").textContent = expiry;
|
||||
}
|
||||
|
||||
private _userExpiry: string;
|
||||
@@ -110,15 +110,15 @@ class DOMInvite implements Invite {
|
||||
const chip = container.querySelector("span.inv-email-chip");
|
||||
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
|
||||
if (address == "") {
|
||||
container.classList.remove("mr-4");
|
||||
icon.classList.remove("ri-mail-line");
|
||||
icon.classList.remove("ri-mail-close-line");
|
||||
chip.classList.remove("~neutral");
|
||||
chip.classList.remove("~critical");
|
||||
chip.classList.remove("chip");
|
||||
chip.classList.remove("button");
|
||||
chip.parentElement.classList.remove("h-full");
|
||||
} else {
|
||||
container.classList.add("mr-4");
|
||||
chip.classList.add("chip");
|
||||
chip.classList.add("button");
|
||||
chip.parentElement.classList.add("h-full");
|
||||
if (address.includes("Failed")) {
|
||||
icon.classList.remove("ri-mail-line");
|
||||
icon.classList.add("ri-mail-close-line");
|
||||
@@ -146,10 +146,11 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
this._right.classList.remove("empty");
|
||||
let innerHTML = `
|
||||
<table class="table inv-table">
|
||||
<table class="table inv-table table-p-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${window.lang.strings("name")}</th>
|
||||
<th class="w-2"></th>
|
||||
<th>${window.lang.strings("date")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -159,6 +160,7 @@ class DOMInvite implements Invite {
|
||||
innerHTML += `
|
||||
<tr>
|
||||
<td>${username}</td>
|
||||
<td class="w-2"></td>
|
||||
<td>${toDateString(new Date(uB[username] * 1000))}</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -266,18 +268,21 @@ class DOMInvite implements Invite {
|
||||
constructor(invite: Invite) {
|
||||
// first create the invite structure, then use our setter methods to fill in the data.
|
||||
this._container = document.createElement('div') as HTMLDivElement;
|
||||
this._container.classList.add("inv");
|
||||
this._container.classList.add("inv", "overflow-visible");
|
||||
|
||||
this._header = document.createElement('div') as HTMLDivElement;
|
||||
this._container.appendChild(this._header);
|
||||
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "elem-pad", "no-pad", "flex-expand", "row", "mt-2", "overflow-y");
|
||||
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "mt-2", "overflow-visible", "gap-2");
|
||||
|
||||
this._codeArea = document.createElement('div') as HTMLDivElement;
|
||||
this._header.appendChild(this._codeArea);
|
||||
this._codeArea.classList.add("inv-codearea");
|
||||
this._codeArea.classList.add("flex", "flex-row", "flex-wrap", "justify-between", "w-full", "items-baseline", "gap-2", "truncate");
|
||||
this._codeArea.innerHTML = `
|
||||
<a class="invite-link text-black dark:text-white font-mono bg-inherit mr-4" href=""></a>
|
||||
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
|
||||
<div class="flex items-baseline gap-x-4 gap-y-2 truncate">
|
||||
<a class="invite-link text-black dark:text-white font-mono bg-inherit truncate" href=""></a>
|
||||
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
|
||||
</div>
|
||||
<span class="inv-duration"></span>
|
||||
`;
|
||||
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
|
||||
copyButton.onclick = () => {
|
||||
@@ -297,16 +302,15 @@ class DOMInvite implements Invite {
|
||||
|
||||
this._infoArea = document.createElement('div') as HTMLDivElement;
|
||||
this._header.appendChild(this._infoArea);
|
||||
this._infoArea.classList.add("inv-infoarea");
|
||||
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-baseline", "gap-2");
|
||||
this._infoArea.innerHTML = `
|
||||
<div class="tooltip left">
|
||||
<span class="inv-email-chip"><i></i></span>
|
||||
<span class="content sm"></span>
|
||||
<div class="tooltip below darker" tabindex="0">
|
||||
<span class="inv-email-chip h-full"><i></i></span>
|
||||
<span class="content sm p-1"></span>
|
||||
</div>
|
||||
<span class="inv-duration mr-4"></span>
|
||||
<span class="button ~critical @low inv-delete">${window.lang.strings("delete")}</span>
|
||||
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
|
||||
<label>
|
||||
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
|
||||
<i class="icon px-2.5 py-2 ri-arrow-down-s-line not-rotated"></i>
|
||||
<input class="inv-toggle-details unfocused" type="checkbox">
|
||||
</label>
|
||||
`;
|
||||
@@ -315,25 +319,30 @@ class DOMInvite implements Invite {
|
||||
|
||||
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
|
||||
toggle.onchange = () => { this.expanded = !this.expanded; };
|
||||
this._header.onclick = (event: Event) => {
|
||||
if (event.target == this._header) {
|
||||
const toggleDetails = (event: Event) => {
|
||||
if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
};
|
||||
this._header.onclick = toggleDetails;
|
||||
|
||||
|
||||
this._details = document.createElement('div') as HTMLDivElement;
|
||||
this._container.appendChild(this._details);
|
||||
this._details.classList.add("card", "~neutral", "@low", "mt-2", "no-pad", "inv-details");
|
||||
this._details.classList.add("card", "~neutral", "@low", "mt-2", "inv-details");
|
||||
const detailsInner = document.createElement('div') as HTMLDivElement;
|
||||
this._details.appendChild(detailsInner);
|
||||
detailsInner.classList.add("inv-row", "flex-expand", "row", "elem-pad", "align-top");
|
||||
detailsInner.classList.add("inv-row", "flex", "flex-row", "flex-wrap", "justify-between", "gap-4");
|
||||
|
||||
this._left = document.createElement('div') as HTMLDivElement;
|
||||
this._left.classList.add("flex", "flex-row", "flex-wrap", "gap-4", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
|
||||
detailsInner.appendChild(this._left);
|
||||
this._left.classList.add("inv-profilearea");
|
||||
const leftLeft = document.createElement("div") as HTMLDivElement;
|
||||
this._left.appendChild(leftLeft);
|
||||
leftLeft.classList.add("inv-profilearea", "min-w-full", "sm:min-w-fit");
|
||||
let innerHTML = `
|
||||
<p class="supra mb-2 top">${window.lang.strings("profile")}</p>
|
||||
<div class="select ~neutral @low inv-profileselect inline-block mb-2">
|
||||
<div class="select ~neutral @low inv-profileselect min-w-full inline-block mb-2">
|
||||
<select>
|
||||
<option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option>
|
||||
</select>
|
||||
@@ -352,7 +361,7 @@ class DOMInvite implements Invite {
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
this._left.innerHTML = innerHTML;
|
||||
leftLeft.innerHTML = innerHTML;
|
||||
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
|
||||
|
||||
if (window.notificationsEnabled) {
|
||||
@@ -364,20 +373,21 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
|
||||
this._middle = document.createElement('div') as HTMLDivElement;
|
||||
detailsInner.appendChild(this._middle);
|
||||
this._middle.classList.add("block");
|
||||
this._left.appendChild(this._middle);
|
||||
this._middle.classList.add("flex", "flex-col", "justify-between");
|
||||
this._middle.innerHTML = `
|
||||
<p class="supra mb-4 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
|
||||
<p class="supra mb-4">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
|
||||
<p class="supra mb-4"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
|
||||
<p class="mb-4 flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
|
||||
<p class="supra top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
|
||||
<p class="supra">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
|
||||
<p class="supra"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
|
||||
<p class="flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
|
||||
`;
|
||||
|
||||
this._right = document.createElement('div') as HTMLDivElement;
|
||||
detailsInner.appendChild(this._right);
|
||||
this._right.classList.add("card", "~neutral", "@low", "inv-created-users");
|
||||
this._right.innerHTML = `<strong class="supra table-header">${window.lang.strings("inviteUsersCreated")}</strong>`;
|
||||
this._right.classList.add("card", "~neutral", "@low", "inv-created-users", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
|
||||
this._right.innerHTML = `<span class="supra table-header">${window.lang.strings("inviteUsersCreated")}</span>`;
|
||||
this._userTable = document.createElement('div') as HTMLDivElement;
|
||||
this._userTable.classList.add("text-sm", "mt-1", );
|
||||
this._right.appendChild(this._userTable);
|
||||
|
||||
|
||||
@@ -470,8 +480,8 @@ export class inviteList implements inviteList {
|
||||
this._list.classList.add("empty");
|
||||
this._list.innerHTML = `
|
||||
<div class="inv inv-empty">
|
||||
<div class="card dark:~d_neutral @low inv-header flex-expand mt-2">
|
||||
<div class="inv-codearea">
|
||||
<div class="card dark:~d_neutral @low inv-header mt-2">
|
||||
<div class="justify-start">
|
||||
<span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const loadLangSelector = (page: string) => {
|
||||
let innerHTML = '';
|
||||
for (let code in req.response) {
|
||||
queryString.set("lang", code);
|
||||
innerHTML += `<a href="?${queryString.toString()}" class="button w-100 al justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
|
||||
innerHTML += `<a href="?${queryString.toString()}" class="button w-full text-left justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
|
||||
queryString.delete("lang");
|
||||
}
|
||||
list.innerHTML = innerHTML;
|
||||
|
||||
@@ -5,6 +5,7 @@ interface Profile {
|
||||
libraries: string;
|
||||
fromUser: string;
|
||||
ombi: boolean;
|
||||
jellyseerr: boolean;
|
||||
referrals_enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -14,9 +15,11 @@ class profile implements Profile {
|
||||
private _adminChip: HTMLSpanElement;
|
||||
private _libraries: HTMLTableDataCellElement;
|
||||
private _ombiButton: HTMLSpanElement;
|
||||
private _ombi: boolean;
|
||||
private _jellyseerrButton: HTMLSpanElement;
|
||||
private _jellyseerr: boolean;
|
||||
private _fromUser: HTMLTableDataCellElement;
|
||||
private _defaultRadio: HTMLInputElement;
|
||||
private _ombi: boolean;
|
||||
private _referralsButton: HTMLSpanElement;
|
||||
private _referralsEnabled: boolean;
|
||||
|
||||
@@ -51,6 +54,21 @@ class profile implements Profile {
|
||||
this._ombiButton.classList.remove("~critical");
|
||||
}
|
||||
}
|
||||
|
||||
get jellyseerr(): boolean { return this._jellyseerr; }
|
||||
set jellyseerr(v: boolean) {
|
||||
if (!window.jellyseerrEnabled) return;
|
||||
this._jellyseerr = v;
|
||||
if (v) {
|
||||
this._jellyseerrButton.textContent = window.lang.strings("delete");
|
||||
this._jellyseerrButton.classList.add("~critical");
|
||||
this._jellyseerrButton.classList.remove("~neutral");
|
||||
} else {
|
||||
this._jellyseerrButton.textContent = window.lang.strings("add");
|
||||
this._jellyseerrButton.classList.add("~neutral");
|
||||
this._jellyseerrButton.classList.remove("~critical");
|
||||
}
|
||||
}
|
||||
|
||||
get fromUser(): string { return this._fromUser.textContent; }
|
||||
set fromUser(v: string) { this._fromUser.textContent = v; }
|
||||
@@ -82,11 +100,14 @@ class profile implements Profile {
|
||||
if (window.ombiEnabled) innerHTML += `
|
||||
<td><span class="button @low profile-ombi"></span></td>
|
||||
`;
|
||||
if (window.jellyseerrEnabled) innerHTML += `
|
||||
<td><span class="button @low profile-jellyseerr"></span></td>
|
||||
`;
|
||||
if (window.referralsEnabled) innerHTML += `
|
||||
<td><span class="button @low profile-referrals"></span></td>
|
||||
`;
|
||||
innerHTML += `
|
||||
<td class="profile-from ellipsis"></td>
|
||||
<td class="profile-from truncate"></td>
|
||||
<td class="profile-libraries"></td>
|
||||
<td><span class="button ~critical @low">${window.lang.strings("delete")}</span></td>
|
||||
`;
|
||||
@@ -96,6 +117,8 @@ class profile implements Profile {
|
||||
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
|
||||
if (window.ombiEnabled)
|
||||
this._ombiButton = this._row.querySelector("span.profile-ombi") as HTMLSpanElement;
|
||||
if (window.jellyseerrEnabled)
|
||||
this._jellyseerrButton = this._row.querySelector("span.profile-jellyseerr") as HTMLSpanElement;
|
||||
if (window.referralsEnabled)
|
||||
this._referralsButton = this._row.querySelector("span.profile-referrals") as HTMLSpanElement;
|
||||
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
|
||||
@@ -112,10 +135,12 @@ class profile implements Profile {
|
||||
this.fromUser = p.fromUser;
|
||||
this.libraries = p.libraries;
|
||||
this.ombi = p.ombi;
|
||||
this.jellyseerr = p.jellyseerr;
|
||||
this.referrals_enabled = p.referrals_enabled;
|
||||
}
|
||||
|
||||
setOmbiFunc = (ombiFunc: (ombi: boolean) => void) => { this._ombiButton.onclick = () => ombiFunc(this._ombi); }
|
||||
setJellyseerrFunc = (jellyseerrFunc: (jellyseerr: boolean) => void) => { this._jellyseerrButton.onclick = () => jellyseerrFunc(this._jellyseerr); }
|
||||
setReferralFunc = (referralFunc: (enabled: boolean) => void) => { this._referralsButton.onclick = () => referralFunc(this._referralsEnabled); }
|
||||
|
||||
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
|
||||
@@ -144,6 +169,7 @@ export class ProfileEditor {
|
||||
private _profiles: { [name: string]: profile } = {};
|
||||
private _default: string;
|
||||
private _ombiProfiles: ombiProfiles;
|
||||
private _jellyseerrProfiles: jellyseerrProfiles;
|
||||
|
||||
private _createForm = document.getElementById("form-add-profile") as HTMLFormElement;
|
||||
private _profileName = document.getElementById("add-profile-name") as HTMLInputElement;
|
||||
@@ -181,7 +207,7 @@ export class ProfileEditor {
|
||||
this._profiles[name].update(name, resp.profiles[name]);
|
||||
} else {
|
||||
this._profiles[name] = new profile(name, resp.profiles[name]);
|
||||
if (window.ombiEnabled)
|
||||
if (window.ombiEnabled) {
|
||||
this._profiles[name].setOmbiFunc((ombi: boolean) => {
|
||||
if (ombi) {
|
||||
this._ombiProfiles.delete(name, (req: XMLHttpRequest) => {
|
||||
@@ -198,7 +224,26 @@ export class ProfileEditor {
|
||||
this._ombiProfiles.load(name);
|
||||
}
|
||||
});
|
||||
if (window.referralsEnabled)
|
||||
}
|
||||
if (window.jellyseerrEnabled) {
|
||||
this._profiles[name].setJellyseerrFunc((jellyseerr: boolean) => {
|
||||
if (jellyseerr) {
|
||||
this._jellyseerrProfiles.delete(name, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status != 204) {
|
||||
window.notifications.customError("errorDeleteJellyseerr", window.lang.notif("errorUnknown"));
|
||||
return;
|
||||
}
|
||||
this._profiles[name].jellyseerr = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.modals.profiles.close();
|
||||
this._jellyseerrProfiles.load(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (window.referralsEnabled) {
|
||||
this._profiles[name].setReferralFunc((enabled: boolean) => {
|
||||
if (enabled) {
|
||||
this.disableReferrals(name);
|
||||
@@ -206,6 +251,7 @@ export class ProfileEditor {
|
||||
this.enableReferrals(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
this._table.appendChild(this._profiles[name].asElement());
|
||||
}
|
||||
}
|
||||
@@ -299,6 +345,8 @@ export class ProfileEditor {
|
||||
|
||||
if (window.ombiEnabled)
|
||||
this._ombiProfiles = new ombiProfiles();
|
||||
if (window.jellyseerrEnabled)
|
||||
this._jellyseerrProfiles = new jellyseerrProfiles();
|
||||
|
||||
this._createButton.onclick = () => _get("/users", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
@@ -366,7 +414,7 @@ export class ombiProfiles {
|
||||
let resp = {} as ombiUser;
|
||||
resp.id = this._select.value;
|
||||
resp.name = this._users[resp.id];
|
||||
_post("/profiles/ombi/" + this._currentProfile, resp, (req: XMLHttpRequest) => {
|
||||
_post("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(this._currentProfile)), resp, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
toggleLoader(button);
|
||||
if (req.status == 200 || req.status == 204) {
|
||||
@@ -379,7 +427,7 @@ export class ombiProfiles {
|
||||
});
|
||||
}
|
||||
|
||||
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + profile, null, post);
|
||||
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
|
||||
|
||||
load = (profile: string) => {
|
||||
this._currentProfile = profile;
|
||||
@@ -401,3 +449,54 @@ export class ombiProfiles {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class jellyseerrProfiles {
|
||||
private _form: HTMLFormElement;
|
||||
private _select: HTMLSelectElement;
|
||||
private _users: { [id: string]: string } = {};
|
||||
private _currentProfile: string;
|
||||
|
||||
constructor() {
|
||||
this._form = document.getElementById("form-jellyseerr-defaults") as HTMLFormElement;
|
||||
this._form.onsubmit = this.send;
|
||||
this._select = this._form.querySelector("select") as HTMLSelectElement;
|
||||
}
|
||||
send = () => {
|
||||
const button = this._form.querySelector("span.submit") as HTMLSpanElement;
|
||||
toggleLoader(button);
|
||||
let encodedProfile = encodeURIComponent(encodeURIComponent(this._currentProfile));
|
||||
_post("/profiles/jellyseerr/" + encodedProfile + "/" + this._select.value, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
toggleLoader(button);
|
||||
if (req.status == 200 || req.status == 204) {
|
||||
window.notifications.customSuccess("jellyseerrDefaults", window.lang.notif("savedProfile"));
|
||||
} else {
|
||||
window.notifications.customError("jellyseerrDefaults", window.lang.notif("errorSavedProfile"));
|
||||
}
|
||||
window.modals.jellyseerrProfile.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/jellyseerr/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
|
||||
|
||||
load = (profile: string) => {
|
||||
this._currentProfile = profile;
|
||||
_get("/jellyseerr/users", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status == 200 && "users" in req.response) {
|
||||
const users = req.response["users"] as ombiUser[];
|
||||
let innerHTML = "";
|
||||
for (let user of users) {
|
||||
this._users[user.id] = user.name;
|
||||
innerHTML += `<option value="${user.id}">${user.name}</option>`;
|
||||
}
|
||||
this._select.innerHTML = innerHTML;
|
||||
window.modals.jellyseerrProfile.show();
|
||||
} else {
|
||||
window.notifications.customError("jellyseerrLoadError", window.lang.notif("errorLoadUsers"))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export class Search {
|
||||
}
|
||||
|
||||
const container = document.createElement("span") as HTMLSpanElement;
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2", "align-bottom");
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col mr-2">
|
||||
<span>${query.name}</span>
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export interface templateEmail {
|
||||
interface emailListEl {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
class MessageEditor {
|
||||
@@ -1092,6 +1093,7 @@ class MessageEditor {
|
||||
private _templ: templateEmail;
|
||||
private _form = document.getElementById("form-editor") as HTMLFormElement;
|
||||
private _header = document.getElementById("header-editor") as HTMLSpanElement;
|
||||
private _aside = document.getElementById("aside-editor") as HTMLElement;
|
||||
private _variables = document.getElementById("editor-variables") as HTMLDivElement;
|
||||
private _variablesLabel = document.getElementById("label-editor-variables") as HTMLElement;
|
||||
private _conditionals = document.getElementById("editor-conditionals") as HTMLDivElement;
|
||||
@@ -1113,6 +1115,12 @@ class MessageEditor {
|
||||
if (this._names[id] !== undefined) {
|
||||
this._header.textContent = this._names[id].name;
|
||||
}
|
||||
this._aside.classList.add("unfocused");
|
||||
if (this._names[id].description != "") {
|
||||
this._aside.textContent = this._names[id].description;
|
||||
this._aside.classList.remove("unfocused");
|
||||
}
|
||||
|
||||
this._templ = req.response as templateEmail;
|
||||
this._textArea.value = this._templ.content;
|
||||
if (this._templ.html == "") {
|
||||
@@ -1212,11 +1220,22 @@ class MessageEditor {
|
||||
if (this._names[id].enabled) {
|
||||
resetButton = `<i class="icon ri-restart-line" title="${window.lang.get("strings", "reset")}"></i>`;
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${this._names[id].name}</td>
|
||||
let innerHTML = `
|
||||
<td>
|
||||
${this._names[id].name}
|
||||
`;
|
||||
if (this._names[id].description != "") innerHTML += `
|
||||
<div class="tooltip right">
|
||||
<i class="icon ri-information-line"></i>
|
||||
<span class="content sm">${this._names[id].description}</span>
|
||||
</div>
|
||||
`;
|
||||
innerHTML += `
|
||||
</td>
|
||||
<td class="table-inline justify-center"><span class="customize-reset">${resetButton}</span></td>
|
||||
<td><span class="button ~info @low" title="${window.lang.get("strings", "edit")}"><i class="icon ri-edit-line"></i></span></td>
|
||||
`;
|
||||
tr.innerHTML = innerHTML;
|
||||
(tr.querySelector("span.button") as HTMLSpanElement).onclick = () => {
|
||||
window.modals.customizeEmails.close()
|
||||
this.loadEditor(id);
|
||||
|
||||
@@ -22,13 +22,18 @@ export class Tabs implements Tabs {
|
||||
|
||||
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
|
||||
this._current = tabID;
|
||||
let baseOffset = -1;
|
||||
for (let t of this.tabs) {
|
||||
if (baseOffset == -1) baseOffset = t.buttonEl.offsetLeft;
|
||||
if (t.tabID == tabID) {
|
||||
t.buttonEl.classList.add("active", "~urge");
|
||||
if (t.preFunc && !noRun) { t.preFunc(); }
|
||||
t.tabEl.classList.remove("unfocused");
|
||||
if (t.postFunc && !noRun) { t.postFunc(); }
|
||||
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
|
||||
// t.buttonEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
|
||||
|
||||
t.buttonEl.parentElement.scrollTo(t.buttonEl.offsetLeft-baseOffset, 0);
|
||||
} else {
|
||||
t.buttonEl.classList.remove("active");
|
||||
t.buttonEl.classList.remove("~urge");
|
||||
|
||||
52
ts/pwr.ts
52
ts/pwr.ts
@@ -2,6 +2,7 @@ import { Modal } from "./modules/modal.js";
|
||||
import { Validator, ValidatorConf } from "./modules/validator.js";
|
||||
import { _post, addLoader, removeLoader } from "./modules/common.js";
|
||||
import { loadLangSelector } from "./modules/lang.js";
|
||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||
|
||||
interface formWindow extends Window {
|
||||
invalidPassword: string;
|
||||
@@ -28,6 +29,10 @@ interface formWindow extends Window {
|
||||
userExpiryHours: number;
|
||||
userExpiryMinutes: number;
|
||||
userExpiryMessage: string;
|
||||
captcha: boolean;
|
||||
reCAPTCHA: boolean;
|
||||
reCAPTCHASiteKey: string;
|
||||
pwrPIN: string;
|
||||
}
|
||||
|
||||
loadLangSelector("pwr");
|
||||
@@ -42,11 +47,26 @@ const rePasswordField = document.getElementById("create-reenter-password") as HT
|
||||
|
||||
window.successModal = new Modal(document.getElementById("modal-success"), true);
|
||||
|
||||
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
|
||||
if (window.captcha && !window.reCAPTCHA && !captchaValid) {
|
||||
oncomplete(false);
|
||||
return;
|
||||
}
|
||||
oncomplete(true);
|
||||
}
|
||||
|
||||
let captcha = new Captcha(window.pwrPIN, window.captcha, window.reCAPTCHA, true);
|
||||
|
||||
declare var grecaptcha: GreCAPTCHA;
|
||||
|
||||
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
|
||||
|
||||
let validatorConf: ValidatorConf = {
|
||||
passwordField: passwordField,
|
||||
rePasswordField: rePasswordField,
|
||||
submitInput: submitInput,
|
||||
submitButton: submitSpan
|
||||
submitButton: submitSpan,
|
||||
validatorFunc: baseValidator
|
||||
};
|
||||
|
||||
var validator = new Validator(validatorConf);
|
||||
@@ -55,6 +75,13 @@ var requirements = validator.requirements;
|
||||
interface sendDTO {
|
||||
pin: string;
|
||||
password: string;
|
||||
captcha_text?: string;
|
||||
}
|
||||
|
||||
if (window.captcha && !window.reCAPTCHA) {
|
||||
captcha.generate();
|
||||
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
|
||||
captcha.input.onkeyup = validator.validate;
|
||||
}
|
||||
|
||||
form.onsubmit = (event: Event) => {
|
||||
@@ -65,12 +92,31 @@ form.onsubmit = (event: Event) => {
|
||||
pin: params.get("pin"),
|
||||
password: passwordField.value
|
||||
};
|
||||
if (window.captcha) {
|
||||
if (window.reCAPTCHA) {
|
||||
send.captcha_text = grecaptcha.getResponse();
|
||||
} else {
|
||||
send.captcha_text = captcha.input.value;
|
||||
}
|
||||
}
|
||||
_post("/reset", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
removeLoader(submitSpan);
|
||||
if (req.status == 400) {
|
||||
for (let type in req.response) {
|
||||
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
|
||||
if (req.response["error"] as string) {
|
||||
const old = submitSpan.textContent;
|
||||
submitSpan.textContent = window.messages[req.response["error"]];
|
||||
submitSpan.classList.add("~critical");
|
||||
submitSpan.classList.remove("~urge");
|
||||
setTimeout(() => {
|
||||
submitSpan.classList.add("~urge");
|
||||
submitSpan.classList.remove("~critical");
|
||||
submitSpan.textContent = old;
|
||||
}, 2000);
|
||||
} else {
|
||||
for (let type in req.response) {
|
||||
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if (req.status != 200) {
|
||||
|
||||
@@ -24,6 +24,7 @@ declare interface Window {
|
||||
discordEnabled: boolean;
|
||||
matrixEnabled: boolean;
|
||||
ombiEnabled: boolean;
|
||||
jellyseerrEnabled: boolean;
|
||||
usernameEnabled: boolean;
|
||||
linkResetEnabled: boolean;
|
||||
token: string;
|
||||
@@ -101,6 +102,7 @@ declare interface Modals {
|
||||
settingsRestart: Modal;
|
||||
settingsRefresh: Modal;
|
||||
ombiProfile?: Modal;
|
||||
jellyseerrProfile?: Modal;
|
||||
profiles: Modal;
|
||||
addProfile: Modal;
|
||||
announce: Modal;
|
||||
|
||||
99
ts/user.ts
99
ts/user.ts
@@ -144,9 +144,9 @@ class ContactMethods {
|
||||
|
||||
append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap");
|
||||
row.classList.add("flex", "flex-row", "justify-between", "my-2", "flex-nowrap");
|
||||
let innerHTML = `
|
||||
<div class="flex items-baseline flex-nowrap ellipsis">
|
||||
<div class="flex items-baseline flex-nowrap truncate">
|
||||
<span class="shield ~urge" alt="${name}">
|
||||
<span class="icon">
|
||||
${icon}
|
||||
@@ -266,13 +266,20 @@ class ReferralCard {
|
||||
get code(): string { return this._code; }
|
||||
set code(c: string) {
|
||||
this._code = c;
|
||||
let url = window.location.href;
|
||||
for (let split of ["#", "?", "account", "my"]) {
|
||||
url = url.split(split)[0];
|
||||
|
||||
let u = new URL(window.location.href);
|
||||
let path = u.pathname;
|
||||
for (let split of ["account", "my"]) {
|
||||
path = path.split(split)[0];
|
||||
}
|
||||
if (url.slice(-1) != "/") { url += "/"; }
|
||||
url = url + "invite/" + this._code;
|
||||
this._url = url;
|
||||
if (path.slice(-1) != "/") { path += "/"; }
|
||||
path = path + "invite/" + this._code;
|
||||
|
||||
u.pathname = path;
|
||||
u.hash = "";
|
||||
u.search = "";
|
||||
|
||||
this._url = u.toString();
|
||||
}
|
||||
|
||||
get remaining_uses(): number { return this._remainingUses; }
|
||||
@@ -637,10 +644,10 @@ document.addEventListener("details-reload", () => {
|
||||
|
||||
if (typeof(messageCard) != "undefined" && messageCard != null) {
|
||||
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
|
||||
setBestRowSpan(messageCard, false);
|
||||
// setBestRowSpan(messageCard, false);
|
||||
// contactCard.querySelector(".content").classList.add("h-100");
|
||||
} else if (!statusCard.classList.contains("unfocused")) {
|
||||
setBestRowSpan(passwordCard, true);
|
||||
// setBestRowSpan(passwordCard, true);
|
||||
}
|
||||
|
||||
if (window.referralsEnabled) {
|
||||
@@ -649,15 +656,69 @@ document.addEventListener("details-reload", () => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
const referral: MyReferral = req.response as MyReferral;
|
||||
referralCard.update(referral);
|
||||
setCardOrder(messageCard);
|
||||
});
|
||||
} else {
|
||||
referralCard.hide();
|
||||
setCardOrder(messageCard);
|
||||
}
|
||||
} else {
|
||||
setCardOrder(messageCard);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const setCardOrder = (messageCard: HTMLElement) => {
|
||||
const cards = document.getElementById("user-cardlist");
|
||||
const children = Array.from(cards.children);
|
||||
const idxs = [...Array(cards.childElementCount).keys()]
|
||||
// The message card is the first element and should always be so, so remove it from the list.
|
||||
const hasMessageCard = !(typeof(messageCard) == "undefined" || messageCard == null);
|
||||
if (hasMessageCard) idxs.shift();
|
||||
const perms = generatePermutations(idxs);
|
||||
let minHeight = 999999;
|
||||
let minHeightPerm: [number[], number[]];
|
||||
for (let perm of perms) {
|
||||
let leftHeight = 0;
|
||||
for (let idx of perm[0]) {
|
||||
leftHeight += (cards.children[idx] as HTMLElement).offsetHeight;
|
||||
}
|
||||
if (hasMessageCard) leftHeight += (cards.children[0] as HTMLElement).offsetHeight;
|
||||
let rightHeight = 0;
|
||||
for (let idx of perm[1]) {
|
||||
rightHeight += (cards.children[idx] as HTMLElement).offsetHeight;
|
||||
}
|
||||
let height = Math.max(leftHeight, rightHeight);
|
||||
// console.log("got height", leftHeight, rightHeight, height, "for", perm);
|
||||
if (height < minHeight) {
|
||||
minHeight = height;
|
||||
minHeightPerm = perm;
|
||||
}
|
||||
}
|
||||
|
||||
const gapDiv = () => {
|
||||
const g = document.createElement("div");
|
||||
g.classList.add("my-4");
|
||||
return g;
|
||||
};
|
||||
|
||||
let addValue = hasMessageCard ? 1 : 0;
|
||||
// if (hasMessageCard) cards.appendChild(children[0]);
|
||||
if (hasMessageCard) cards.appendChild(gapDiv());
|
||||
for (let side of minHeightPerm) {
|
||||
for (let i = 0; i < side.length; i++) {
|
||||
// (cards.children[side[i]] as HTMLElement).style.order = (i+addValue).toString();
|
||||
children[side[i]].remove();
|
||||
cards.appendChild(children[side[i]]);
|
||||
cards.appendChild(gapDiv());
|
||||
}
|
||||
// addValue += side.length;
|
||||
}
|
||||
|
||||
console.log("Shortest order:", minHeightPerm);
|
||||
};
|
||||
|
||||
const login = new Login(window.modals.login as Modal, "/my/", "opaque");
|
||||
login.onLogin = () => {
|
||||
console.log("Logged in.");
|
||||
@@ -699,6 +760,24 @@ const computeRealHeight = (el: HTMLElement): number => {
|
||||
return total;
|
||||
}
|
||||
|
||||
const generatePermutations = (xs: number[]): [number[], number[]][] => {
|
||||
const l = xs.length;
|
||||
let out: [number[], number[]][] = [];
|
||||
for (let i = 0; i < (l << 1); i++) {
|
||||
let incl = [];
|
||||
let excl = [];
|
||||
for (let j = 0; j < l; j++) {
|
||||
if (i & (1 << j)) {
|
||||
incl.push(xs[j]);
|
||||
} else {
|
||||
excl.push(xs[j]);
|
||||
}
|
||||
}
|
||||
out.push([incl, excl]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
login.bindLogout(document.getElementById("logout-button"));
|
||||
|
||||
login.login("", "");
|
||||
|
||||
@@ -45,13 +45,13 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
|
||||
respond(500, "Contact Admin", gc)
|
||||
return
|
||||
}
|
||||
app.info.Println("UserToken requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc)
|
||||
app.logIpInfo(gc, true, "UserToken requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc)
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
app.info.Println("UserToken request (refresh token)")
|
||||
app.logIpInfo(gc, true, "UserToken request (refresh token)")
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh")
|
||||
if !ok {
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user