Compare commits

...

54 Commits

Author SHA1 Message Date
Harvey Tindall
e68dccbc17 keep language choice in url when changing tabs 2021-01-15 21:16:11 +00:00
Harvey Tindall
ffc62574ec Fix server push and use Link header to load CSS
Nginx with http2_push_preload on will convert the Link header to server
pushes, so we use it to load css.
2021-01-15 18:57:12 +00:00
Harvey Tindall
ca0889aaab Merge branch 'main' of github.com:hrfee/jfa-go into main
made a mistake, tried to fix it, messed up again.
2021-01-15 14:47:12 +00:00
Harvey Tindall
772e12d11c add optional tls/http2 support
Allows for http2 server push, see the advanced section.
2021-01-15 14:43:32 +00:00
Harvey Tindall
7d04487b18 update CONTRIBUTING.md 2021-01-15 14:43:32 +00:00
Harvey Tindall
0b482116bb fix display of username box on add account modal 2021-01-15 14:43:32 +00:00
Harvey Tindall
a579bcd463 add finished french for admin 2021-01-15 14:43:32 +00:00
Harvey Tindall
ab7017ff12 fix spelling in french email 2021-01-15 14:43:32 +00:00
Harvey Tindall
3d5bea003a Fix email language selection, add finished french emails 2021-01-15 14:43:32 +00:00
Harvey Tindall
bc99dc34ee Add email translation, add part of french translations
Admin translation from @Killianbe, Email translation from
@Cornichon420. French is currently not functional, a few things are
missing which i'm waiting on.
2021-01-15 14:43:32 +00:00
Harvey Tindall
965c449f1c attempt to use http2 server push 2021-01-15 14:43:32 +00:00
Harvey Tindall
4679c6f355 add language selector to admin 2021-01-15 14:43:32 +00:00
Harvey Tindall
a3351f4da8 separate options for form and admin language 2021-01-15 14:43:32 +00:00
Harvey Tindall
422f13202b Use lang file in typescript 2021-01-15 14:43:31 +00:00
Harvey Tindall
c470e40737 Start adding translation support for admin 2021-01-15 14:43:31 +00:00
Harvey Tindall
0f92ce2166 update CONTRIBUTING.md 2021-01-15 13:48:18 +00:00
Harvey Tindall
b1becb9ef5 fix display of username box on add account modal 2021-01-15 13:48:18 +00:00
Harvey Tindall
3c1599b6b7 add finished french for admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
3e53b742f4 fix spelling in french email 2021-01-15 13:48:18 +00:00
Harvey Tindall
5401593279 Fix email language selection, add finished french emails 2021-01-15 13:48:18 +00:00
Harvey Tindall
0710e05479 Add email translation, add part of french translations
Admin translation from @Killianbe, Email translation from
@Cornichon420. French is currently not functional, a few things are
missing which i'm waiting on.
2021-01-15 13:48:18 +00:00
Harvey Tindall
1707d011a2 attempt to use http2 server push 2021-01-15 13:48:18 +00:00
Harvey Tindall
5e8d7944bd add language selector to admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
2d2727f7e8 separate options for form and admin language 2021-01-15 13:48:18 +00:00
Harvey Tindall
c72282613d Use lang file in typescript 2021-01-15 13:48:18 +00:00
Harvey Tindall
4ac62a107c Start adding translation support for admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
a102199d5a include code in invite form instead of getting from url
potentially solves #34?
2021-01-14 14:22:20 +00:00
Harvey Tindall
3c799b8783 remove debug printfs 2021-01-11 19:19:19 +00:00
Harvey Tindall
3fbbc7f620 add language selector to form 2021-01-11 19:17:43 +00:00
Harvey Tindall
461efa7f60 oops 2021-01-11 16:10:04 +00:00
Harvey Tindall
1321f8df50 mention contributing.md 2021-01-11 16:09:30 +00:00
Harvey Tindall
a081f3a799 add contribution notes 2021-01-11 16:07:49 +00:00
Harvey Tindall
e532000ad0 Mention emby in README, add notices about password resets 2021-01-10 16:10:03 +00:00
Harvey Tindall
8d0dc232d7 option to substitute "Jellyfin" in form.html
setting is jellyfin/substitute_jellyfin_strings.
2021-01-10 16:10:03 +00:00
Harvey Tindall
f5602f1e96 change settings description and console warning 2021-01-10 16:10:03 +00:00
Harvey Tindall
d9e1e2f58b compiles, basic issues fixed
Server type is found under the Jellyfin settings tab, where you can
change it to emby. Currently:

* logs in
* creates users
* parses accounts
2021-01-10 16:10:03 +00:00
Harvey Tindall
5d56ed5378 fix most incompatibilites, start separating api clients 2021-01-10 16:10:03 +00:00
Harvey Tindall
4aae655180 live validation on form, change special character definition
The internal array of special characters was lacking, so a character is
now special when not a digit and (uppercase form) == (lowercase form).
2021-01-09 01:00:27 +00:00
Harvey Tindall
6860933498 functional continue button in form, hide empty contactMessage box 2021-01-09 00:07:19 +00:00
alexh-name
377c8d3e4e fix typo in form/en-us 2021-01-08 23:58:48 +00:00
Richard de Boer
74bbfdf5c2 add dutch translation of account creation form 2021-01-08 23:57:18 +00:00
Harvey Tindall
0171fb8569 dont attempt to release PRs on buildrone 2021-01-08 23:52:36 +00:00
Harvey Tindall
fdc97b4e86 rename ts to typescript
unusual name conflict meant this step gets skipped.
2021-01-05 18:40:19 +00:00
Harvey Tindall
eb370d64df Merge a17t-redesign, kinda ts-ify setup.js
the web ui has been redesigned with the a17t toolkit, which imo looks a
lot better than bootstrap. This also brought a complete rework of the
web code, which now makes a lot more sense hopefully. the setup page is
still stuck with bootstrap, its not much of a priority but i'll rewrite
it eventually.
2021-01-05 18:16:23 +00:00
Harvey Tindall
69bf81b658 change comment 2021-01-05 17:34:27 +00:00
Harvey Tindall
9125273036 Merge dependabot PR 2020-12-18 15:46:34 +00:00
Harvey Tindall
ee6f81b9e9 Add ability to revert to non-hyphenated user IDs
The first 10.7.0 build i tried used hyphens, but a later one didn't.
emails.json can now be converted between the two forms depending on what
the server uses.
2020-12-18 15:44:19 +00:00
Harvey Tindall
72eb51e9c0 Merge pull request #20 from hrfee/dependabot/npm_and_yarn/ini-1.3.8
Bump ini from 1.3.5 to 1.3.8
2020-12-13 22:02:38 +00:00
dependabot[bot]
f3833f1433 Bump ini from 1.3.5 to 1.3.8
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-13 04:01:02 +00:00
Harvey Tindall
c79f86137e separate head into template, add description meta tag 2020-12-07 22:46:17 +00:00
Harvey Tindall
8ef27f7fda upgrade bootstrap and popper js
bootstrap css was on alpha 3 while js was on alpha 1 previously.
2020-12-05 22:36:03 +00:00
Harvey Tindall
a1e30ff5db fix/improve parsing of last active dates
parseDT only uses the magic json.Unmarshal method if theres an error
with the better version. Error came from some times being sent without a
"Z" at the end denoting UTC.
2020-12-03 20:49:50 +00:00
Harvey Tindall
3c952d21f7 fix 10.7.0 compatibility, simplify scss
Hyphens are added to user IDs from 10.7.0, so if the server is running
it, emails.json will be modified to include them. The existing file is
backed up. Also, scss files have been simplified since bs4-jf and bs5-jf share
much of the same content.
2020-11-29 18:01:10 +00:00
Harvey Tindall
9dbf60e3df add URL base option for subfolder proxies
also cleaned up the naming of some things.
2020-11-22 16:36:43 +00:00
118 changed files with 7723 additions and 6515 deletions

View File

@@ -45,3 +45,31 @@ steps:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
trigger:
branch:
- main
event:
exclude:
- pull_request
---
name: jfa-go-pr
kind: pipeline
type: docker
steps:
- name: build
image: golang:latest
commands:
- apt update -y
- apt install build-essential python3-pip curl software-properties-common sed upx -y
- (curl -sL https://deb.nodesource.com/setup_14.x | bash -)
- apt install nodejs
- curl -sL https://git.io/goreleaser > goreleaser.sh
- chmod +x goreleaser.sh
- ./goreleaser.sh --snapshot --skip-publish --rm-dist
trigger:
event:
include:
- pull_request

26
.gitignore vendored
View File

@@ -1,26 +1,14 @@
node_modules/
passwordreset*.json
mail/*.html
scss/*.css*
scss/bs4/*.css*
scss/bs5/*.css*
data/static/*.css
data/static/*.js
data/static/*.js.map
data/static/ts/
data/static/modules/
!data/static/setup.js
data/config-base.json
data/config-default.ini
data/*.html
data/*.txt
data/docs/
dist/*
jfa-go
dist/
build/
pkg/
old/
data/
version.go
notes
docs/*
lang/langtostruct.py
config-payload.json
!docs/go.mod
server.key
server.pem
server.crt

View File

@@ -7,14 +7,20 @@ release:
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data
- cp -r static data/web
- cp -r css data/web/
- npm install
- cp node_modules/a17t/dist/a17t.css data/web/css/
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- cp -r lang data/
- python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
- python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 -m pip install libsass
- npm install
- python3 scss/compile.py
- python3 mail/generate.py
- python3 mail/generate.py -o data/
- python3 version.py {{.Version}} version.go
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify'
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=./data/web/js/ --minify'
- go get -u github.com/swaggo/swag/cmd/swag
- swag init -g main.go
builds:
@@ -37,9 +43,8 @@ archives:
amd64: x86_64
files:
- data/*
- data/templates/*
- data/static/*
- data/static/modules/*
- data/**/*
- data/**/**/*
checksum:
name_template: 'checksums.txt'
snapshot:

12
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,12 @@
#### Translation
Currently the admin page, account creation form and emails can be translated. Strings are defined in `lang/<admin/form/email>/<country-code>.json` (country code as in `en-us`, `fr-fr`, e.g). You can see the existing ones [here](https://github.com/hrfee/jfa-go/tree/main/lang).
Make sure to define `name` in the `meta` section, and you can optionally add an `author` value there as well. If you can, make a pull request with your new file. If not, email me or create an issue.
#### 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.
If you need to test your changes:
* `make debug` will build everything, and include sourcemaps for typescript. This should be the first thing you run.
* `make compile` compiles go into `build/jfa-go`.
* `make ts-debug` will compile typescript w/ sourcemaps into `build/data/web/js`.
* `make copy` will copy css, html, language and static files into `build/data`.

View File

@@ -7,7 +7,7 @@ RUN apt update -y \
&& (curl -sL https://deb.nodesource.com/setup_14.x | bash -) \
&& apt install nodejs \
&& (cd /opt/build; make all; make compress) \
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/templates/setup.html
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM golang:latest

View File

@@ -1,33 +1,30 @@
npm:
$(info installing npm dependencies)
npm install
configuration:
$(info Fixing config-base)
python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
-mkdir -p build/data
python3 config/fixconfig.py -i config/config-base.json -o build/data/config-base.json
$(info Generating config-default.ini)
python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
sass:
$(info Getting libsass)
python3 -m pip install libsass
$(info Getting node dependencies)
npm install
$(info Compiling sass)
python3 scss/compile.py
python3 config/generate_ini.py -i config/config-base.json -o build/data/config-default.ini
email:
$(info Generating email html)
python3 mail/generate.py
python3 mail/generate.py -o build/data/
typescript:
$(info Compiling typescript)
npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify
-rm -r data/static/ts
-rm -r data/static/typings
-rm data/static/*.map
$(info compiling typescript)
-mkdir -p build/data/web/js
-npx esbuild ts/*.ts ts/modules/*.ts --outdir=./build/data/web/js/
ts-debug:
-npx tsc -p ts/ --sourceMap
-rm -r data/static/ts
-rm -r data/static/typings
cp -r ts data/static/
$(info compiling typescript w/ sourcemaps)
-mkdir -p build/data/web/js
-npx esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./build/data/web/js/
-rm -r build/data/web/js/ts
$(info copying typescript)
cp -r ts build/data/web/js
swagger:
go get github.com/swaggo/swag/cmd/swag
@@ -47,11 +44,22 @@ compress:
upx --lzma build/jfa-go
copy:
$(info Copying data)
cp -r data build/
$(info copying css)
-mkdir -p build/data/web/css
cp -r css build/data/web/
cp node_modules/a17t/dist/a17t.css build/data/web/css/
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 build/data/web/css/
$(info copying html)
cp -r html build/data/
$(info copying static data)
-mkdir -p build/data/web
cp -r static/* build/data/web/
$(info copying language files)
cp -r lang build/data/
install:
cp -r build $(DESTDIR)/jfa-go
all: configuration sass email version typescript swagger compile copy
debug: configuration sass email version ts-debug swagger compile copy
all: configuration npm email version typescript swagger compile copy
debug: configuration npm email version ts-debug swagger compile copy

View File

@@ -1,6 +1,8 @@
# ![jfa-go](data/static/banner.svg)
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
![jfa-go](images/banner.svg)
[![Build Status](https://drone.hrfee.pw/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.pw/hrfee/jfa-go)
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
---
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.
I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) in Go mainly as a learning experience, but also to slightly improve speeds and efficiency.
@@ -21,16 +23,15 @@ I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jelly
* 🌓 Customizable look
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* Optionally provide custom CSS
## Interface
#### Interface
<p align="center">
<img src="https://github.com/hrfee/jfa-go/blob/main/images/demo.gif" width="100%"></img>
<img src="images/demo.gif" width="100%"></img>
</p>
<p align="center">
<img src="https://github.com/hrfee/jfa-go/blob/main/images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="https://github.com/hrfee/jfa-go/blob/main/images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
<img src="images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
</p>
#### Install
@@ -48,6 +49,7 @@ For [docker](https://hub.docker.com/repository/docker/hrfee/jfa-go), run:
docker create \
--name "jfa-go" \ # Whatever you want to name it
-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
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
@@ -87,3 +89,6 @@ If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts`
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### Contributing
See [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).

38
README.md.old Normal file
View File

@@ -0,0 +1,38 @@
This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code.
#### todo
**general**
* [x] modal implementation
* [x] animations
* [x] utilities
* [x] CSS for light & dark
**admin**
* [x] invites tab
* [x] accounts tab
* [x] settings tab
* [x] modals
* [ ] integration with existing code
**invites**
* [x] page design
* [ ] integration with existing code
#### screenshots
##### dark
<p>
<img src="images/dark/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/dark/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/dark/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/dark/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/dark/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>
##### light
<p>
<img src="images/light/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/light/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/light/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/light/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/light/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>

259
api.go
View File

@@ -3,8 +3,6 @@ package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"strconv"
"strings"
"time"
@@ -105,12 +103,12 @@ func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) {
}
func (app *appContext) checkInvites() {
current_time := time.Now()
currentTime := time.Now()
app.storage.loadInvites()
changed := false
for code, data := range app.storage.invites {
expiry := data.ValidTill
if !current_time.After(expiry) {
if !currentTime.After(expiry) {
continue
}
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
@@ -144,7 +142,7 @@ func (app *appContext) checkInvites() {
}
func (app *appContext) checkInvite(code string, used bool, username string) bool {
current_time := time.Now()
currentTime := time.Now()
app.storage.loadInvites()
changed := false
inv, match := app.storage.invites[code]
@@ -152,7 +150,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
return false
}
expiry := inv.ValidTill
if current_time.After(expiry) {
if currentTime.After(expiry) {
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
@@ -188,7 +186,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
// 0 means infinite i guess?
newInv.RemainingUses -= 1
}
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(current_time)})
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)})
if !del {
app.storage.invites[code] = newInv
}
@@ -256,15 +254,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
}
if len(app.storage.policy) != 0 {
status, err = app.jf.SetPolicy(id, app.storage.policy)
if !(status == 200 || status == 204) {
if !(status == 200 || status == 204 || err == nil) {
app.err.Printf("%s: Failed to set user policy: Code %d", req.Username, status)
app.debug.Printf("%s: Error: %s", req.Username, err)
}
}
if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 {
status, err = app.jf.SetConfiguration(id, app.storage.configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, app.storage.displayprefs)
} else {
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Username, status)
}
}
@@ -364,8 +364,9 @@ func (app *appContext) NewUser(gc *gin.Context) {
if len(profile.Policy) != 0 {
app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile)
status, err = app.jf.SetPolicy(id, profile.Policy)
if !(status == 200 || status == 204) {
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
app.debug.Printf("%s: Error: %s", req.Code, err)
}
}
if len(profile.Configuration) != 0 && len(profile.Displayprefs) != 0 {
@@ -373,8 +374,10 @@ func (app *appContext) NewUser(gc *gin.Context) {
status, err = app.jf.SetConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
} else {
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
app.debug.Printf("%s: Error: %s", req.Code, err)
}
}
}
@@ -452,7 +455,7 @@ func (app *appContext) DeleteUser(gc *gin.Context) {
app.err.Printf("%s: Failed to send to %s", userID, address)
app.debug.Printf("%s: Error: %s", userID, err)
} else {
app.info.Printf("%s: Sent invite email to %s", userID, address)
app.info.Printf("%s: Sent deletion email to %s", userID, address)
}
}(userID, req.Reason, addr.(string))
}
@@ -482,18 +485,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
app.debug.Println("Generating new invite")
app.storage.loadInvites()
gc.BindJSON(&req)
current_time := time.Now()
valid_till := current_time.AddDate(0, 0, req.Days)
valid_till = valid_till.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
currentTime := time.Now()
validTill := currentTime.AddDate(0, 0, req.Days)
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
// make sure code doesn't begin with number
invite_code := shortuuid.New()
_, err := strconv.Atoi(string(invite_code[0]))
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
invite_code = shortuuid.New()
_, err = strconv.Atoi(string(invite_code[0]))
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
var invite Invite
invite.Created = current_time
invite.Created = currentTime
if req.MultipleUses {
if req.NoLimit {
invite.NoLimit = true
@@ -503,21 +506,21 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
} else {
invite.RemainingUses = 1
}
invite.ValidTill = valid_till
invite.ValidTill = validTill
if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", invite_code)
app.debug.Printf("%s: Sending invite email", inviteCode)
invite.Email = req.Email
msg, err := app.email.constructInvite(invite_code, invite, app)
msg, err := app.email.constructInvite(inviteCode, invite, app)
if err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: Failed to construct invite email", invite_code)
app.debug.Printf("%s: Error: %s", invite_code, err)
app.err.Printf("%s: Failed to construct invite email", inviteCode)
app.debug.Printf("%s: Error: %s", inviteCode, err)
} else if err := app.email.send(req.Email, msg); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: %s", invite_code, invite.Email)
app.debug.Printf("%s: Error: %s", invite_code, err)
app.err.Printf("%s: %s", inviteCode, invite.Email)
app.debug.Printf("%s: Error: %s", inviteCode, err)
} else {
app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
app.info.Printf("%s: Sent invite email to %s", inviteCode, req.Email)
}
}
if req.Profile != "" {
@@ -527,7 +530,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Profile = "Default"
}
}
app.storage.invites[invite_code] = invite
app.storage.invites[inviteCode] = invite
app.storage.storeInvites()
respondBool(200, true, gc)
}
@@ -662,6 +665,9 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
gc.BindJSON(&req)
name := req.Name
if _, ok := app.storage.profiles[name]; ok {
if app.storage.defaultProfile == name {
app.storage.defaultProfile = ""
}
delete(app.storage.profiles, name)
}
app.storage.storeProfiles()
@@ -834,10 +840,25 @@ type dateToParse struct {
Parsed time.Time `json:"parseme"`
}
// json magically parses datetimes so why not
func parseDt(date string) time.Time {
func parseDT(date string) time.Time {
// decent method
dt, err := time.Parse("2006-01-02T15:04:05.000000", date)
if err == nil {
return dt
}
// emby method
dt, err = time.Parse("2006-01-02T15:04:05.0000000+00:00", date)
if err == nil {
return dt
}
// magic method
// some stored dates from jellyfin have no timezone at the end, if not we assume UTC
if date[len(date)-1] != 'Z' {
date += "Z"
}
timeJSON := []byte("{ \"parseme\": \"" + date + "\" }")
var parsed dateToParse
// Magically turn it into a time.Time
json.Unmarshal(timeJSON, &parsed)
return parsed.Parsed
}
@@ -864,7 +885,7 @@ func (app *appContext) GetUsers(gc *gin.Context) {
var user respUser
user.LastActive = "n/a"
if jfUser["LastActivityDate"] != nil {
date := parseDt(jfUser["LastActivityDate"].(string))
date := parseDT(jfUser["LastActivityDate"].(string))
user.LastActive = app.formatDatetime(date)
}
user.ID = jfUser["Id"].(string)
@@ -972,7 +993,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
// @Produce json
// @Param userSettingsDTO body userSettingsDTO true "Parameters for applying settings"
// @Success 200 {object} errorListDTO
// @Failure 500 {object} errorListDTO "Lists of errors that occured while applying settings"
// @Failure 500 {object} errorListDTO "Lists of errors that occurred while applying settings"
// @Router /users/settings [post]
// @Security Bearer
// @tags Profiles & Settings
@@ -1056,60 +1077,74 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
// @Summary Get jfa-go configuration.
// @Produce json
// @Success 200 {object} configDTO "Uses the same format as config-base.json"
// @Success 200 {object} settings "Uses the same format as config-base.json"
// @Router /config [get]
// @Security Bearer
// @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := map[string]interface{}{}
langPath := filepath.Join(app.local_path, "lang", "form")
app.lang.langFiles, _ = ioutil.ReadDir(langPath)
app.lang.langOptions = make([]string, len(app.lang.langFiles))
chosenLang := app.config.Section("ui").Key("language").MustString("en-us") + ".json"
for i, f := range app.lang.langFiles {
if f.Name() == chosenLang {
app.lang.chosenIndex = i
}
var langFile map[string]interface{}
file, _ := ioutil.ReadFile(filepath.Join(langPath, f.Name()))
json.Unmarshal(file, &langFile)
if meta, ok := langFile["meta"]; ok {
app.lang.langOptions[i] = meta.(map[string]interface{})["name"].(string)
resp := app.configBase
// Load language options
loadLangs := func(langs *map[string]map[string]interface{}, settingsKey string) (string, []string) {
langOptions := make([]string, len(*langs))
chosenLang := app.config.Section("ui").Key("language" + settingsKey).MustString("en-us")
chosenLangName := (*langs)[chosenLang]["meta"].(map[string]interface{})["name"].(string)
i := 0
for _, lang := range *langs {
langOptions[i] = lang["meta"].(map[string]interface{})["name"].(string)
i++
}
return chosenLangName, langOptions
}
for section, settings := range app.configBase {
if section == "order" {
resp[section] = settings.([]interface{})
} else {
resp[section] = make(map[string]interface{})
for key, values := range settings.(map[string]interface{}) {
if key == "order" {
resp[section].(map[string]interface{})[key] = values.([]interface{})
} else {
resp[section].(map[string]interface{})[key] = values.(map[string]interface{})
if key != "meta" {
dataType := resp[section].(map[string]interface{})[key].(map[string]interface{})["type"].(string)
configKey := app.config.Section(section).Key(key)
if dataType == "number" {
if val, err := configKey.Int(); err == nil {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = val
}
} else if dataType == "bool" {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.MustBool(false)
} else if dataType == "select" && key == "language" {
resp[section].(map[string]interface{})[key].(map[string]interface{})["options"] = app.lang.langOptions
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = app.lang.langOptions[app.lang.chosenIndex]
} else {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.String()
}
}
}
formChosen, formOptions := loadLangs(&app.storage.lang.Form, "-form")
fl := resp.Sections["ui"].Settings["language-form"]
fl.Options = formOptions
fl.Value = formChosen
adminChosen, adminOptions := loadLangs(&app.storage.lang.Admin, "-admin")
al := resp.Sections["ui"].Settings["language-admin"]
al.Options = adminOptions
al.Value = adminChosen
emailOptions := make([]string, len(app.storage.lang.Email))
chosenLang := app.config.Section("email").Key("language").MustString("en-us")
emailChosen := app.storage.lang.Email.get(chosenLang, "meta", "name")
i := 0
for langName := range app.storage.lang.Email {
emailOptions[i] = app.storage.lang.Email.get(langName, "meta", "name")
i++
}
el := resp.Sections["email"].Settings["language"]
el.Options = emailOptions
el.Value = emailChosen
for sectName, section := range resp.Sections {
for settingName, setting := range section.Settings {
val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName]
switch setting.Type {
case "text", "email", "select", "password":
s.Value = val.MustString("")
case "number":
s.Value = val.MustInt(0)
case "bool":
s.Value = val.MustBool(false)
}
resp.Sections[sectName].Settings[settingName] = s
}
}
// resp["jellyfin"].(map[string]interface{})["language"].(map[string]interface{})["options"].([]string)
resp.Sections["ui"].Settings["language-form"] = fl
resp.Sections["ui"].Settings["language-admin"] = al
resp.Sections["email"].Settings["language"] = el
t := resp.Sections["jellyfin"].Settings["type"]
opts := make([]string, len(serverTypes))
i = 0
for _, v := range serverTypes {
opts[i] = v
i++
}
t.Options = opts
t.Value = serverTypes[app.config.Section("jellyfin").Key("type").MustString("jellyfin")]
resp.Sections["jellyfin"].Settings["type"] = t
gc.JSON(200, resp)
}
@@ -1124,7 +1159,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req configDTO
gc.BindJSON(&req)
tempConfig, _ := ini.Load(app.config_path)
tempConfig, _ := ini.Load(app.configPath)
for section, settings := range req {
if section != "restart-program" {
_, err := tempConfig.GetSection(section)
@@ -1132,20 +1167,41 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
tempConfig.NewSection(section)
}
for setting, value := range settings.(map[string]interface{}) {
if section == "ui" && setting == "language" {
for i, lang := range app.lang.langOptions {
if value.(string) == lang {
tempConfig.Section(section).Key(setting).SetValue(strings.Replace(app.lang.langFiles[i].Name(), ".json", "", 1))
if section == "ui" && setting == "language-form" {
for key, lang := range app.storage.lang.Form {
if lang["meta"].(map[string]interface{})["name"].(string) == value.(string) {
tempConfig.Section("ui").Key("language-form").SetValue(key)
break
}
}
} else {
} else if section == "ui" && setting == "language-admin" {
for key, lang := range app.storage.lang.Admin {
if lang["meta"].(map[string]interface{})["name"].(string) == value.(string) {
tempConfig.Section("ui").Key("language-admin").SetValue(key)
break
}
}
} else if section == "email" && setting == "language" {
for key := range app.storage.lang.Email {
if app.storage.lang.Email.get(key, "meta", "name") == value.(string) {
tempConfig.Section("email").Key("language").SetValue(key)
break
}
}
} else if section == "jellyfin" && setting == "type" {
for k, v := range serverTypes {
if v == value.(string) {
tempConfig.Section("jellyfin").Key("type").SetValue(k)
break
}
}
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string))
}
}
}
}
tempConfig.SaveTo(app.config_path)
tempConfig.SaveTo(app.configPath)
app.debug.Println("Config saved")
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) {
@@ -1160,11 +1216,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
"special characters": app.config.Section("password_validation").Key("special").MustInt(0),
"length": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase": app.config.Section("password_validation").Key("lower").MustInt(0),
"number": app.config.Section("password_validation").Key("number").MustInt(0),
"special": app.config.Section("password_validation").Key("special").MustInt(0),
}
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
for key := range validatorConf {
@@ -1193,6 +1249,31 @@ func (app *appContext) Logout(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Returns a map of available language codes to their full names, usable in the lang query parameter.
// @Produce json
// @Success 200 {object} langDTO
// @Failure 500 {object} stringResponse
// @Router /lang [get]
// @tags Other
func (app *appContext) GetLanguages(gc *gin.Context) {
page := gc.Param("page")
resp := langDTO{}
if page == "form" {
for key, lang := range app.storage.lang.Form {
resp[key] = lang["meta"].(map[string]interface{})["name"].(string)
}
} else if page == "admin" {
for key, lang := range app.storage.lang.Admin {
resp[key] = lang["meta"].(map[string]interface{})["name"].(string)
}
}
if len(resp) == 0 {
respond(500, "Couldn't get languages", gc)
return
}
gc.JSON(200, resp)
}
// func Restart() error {
// defer func() {
// if r := recover(); r != nil {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
@@ -36,7 +37,7 @@ func (app *appContext) loadDefaults() (err error) {
func (app *appContext) loadConfig() error {
var err error
app.config, err = ini.Load(app.config_path)
app.config, err = ini.Load(app.configPath)
if err != nil {
return err
}
@@ -48,37 +49,50 @@ func (app *appContext) loadConfig() error {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
if key.Name() != "html_templates" {
key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json"))))
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template"} {
// if app.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.data_path, (key + ".json"))))
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.local_path, "email.html")))
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.local_path, "email.txt")))
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.localPath, "email.html")))
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.localPath, "email.txt")))
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.local_path, "invite-email.html")))
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.local_path, "invite-email.txt")))
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.localPath, "invite-email.html")))
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.localPath, "invite-email.txt")))
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.local_path, "expired.html")))
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.local_path, "expired.txt")))
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.localPath, "expired.html")))
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt")))
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.localPath, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.localPath, "created.txt")))
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.local_path, "deleted.html")))
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.local_path, "deleted.txt")))
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.localPath, "deleted.html")))
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.localPath, "deleted.txt")))
app.config.Section("jellyfin").Key("version").SetValue(VERSION)
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))
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
oldFormLang := app.config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenFormLang = oldFormLang
}
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenFormLang = newFormLang
}
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
app.email = NewEmailer(app)
return nil

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,17 @@ args = parser.parse_args()
with open(args.input, 'r') as f:
config = json.load(f)
newconfig = {"order": []}
newconfig = {"sections": {}, "order": []}
for sect in config:
for sect in config["sections"]:
newconfig["order"].append(sect)
newconfig[sect] = {}
newconfig[sect]["order"] = []
newconfig[sect]["meta"] = config[sect]["meta"]
for setting in config[sect]:
if setting != "meta":
newconfig[sect]["order"].append(setting)
newconfig[sect][setting] = config[sect][setting]
newconfig["sections"][sect] = {}
newconfig["sections"][sect]["order"] = []
newconfig["sections"][sect]["meta"] = config["sections"][sect]["meta"]
newconfig["sections"][sect]["settings"] = {}
for setting in config["sections"][sect]["settings"]:
newconfig["sections"][sect]["order"].append(setting)
newconfig["sections"][sect]["settings"][setting] = config["sections"][sect]["settings"][setting]
with open(args.output, 'w') as f:
f.write(json.dumps(newconfig, indent=4))

View File

@@ -14,18 +14,19 @@ def generate_ini(base_file, ini_file):
ini = configparser.RawConfigParser(allow_no_value=True)
for section in config_base:
for section in config_base["sections"]:
ini.add_section(section)
for entry in config_base[section]:
if "description" in config_base[section][entry]:
ini.set(section, "; " + config_base[section][entry]["description"])
if entry != "meta":
value = config_base[section][entry]["value"]
if isinstance(value, bool):
value = str(value).lower()
else:
value = str(value)
ini.set(section, entry, value)
if "meta" in config_base["sections"][section]:
ini.set(section, "; " + config_base["sections"][section]["meta"]["description"])
for entry in config_base["sections"][section]["settings"]:
if "description" in config_base["sections"][section]["settings"][entry]:
ini.set(section, "; " + config_base["sections"][section]["settings"][entry]["description"])
value = config_base["sections"][section]["settings"][entry]["value"]
if isinstance(value, bool):
value = str(value).lower()
else:
value = str(value)
ini.set(section, entry, value)
with open(Path(ini_file), "w") as config_file:
ini.write(config_file)

View File

@@ -1,57 +0,0 @@
import json
with open("config-formatted.json", "r") as f:
config = json.load(f)
indent = 0
def writeln(ln):
global indent
if "}" in ln and "{" not in ln:
indent -= 1
s.write(("\t" * indent) + ln + "\n")
if "{" in ln and "}" not in ln:
indent += 1
with open("configStruct.go", "w") as s:
writeln("package main")
writeln("")
writeln("type Metadata struct{")
writeln('Name string `json:"name"`')
writeln('Description string `json:"description"`')
writeln("}")
writeln("")
writeln("type Config struct{")
if "order" in config:
writeln('Order []string `json:"order"`')
for section in [x for x in config.keys() if x != "order"]:
title = "".join([x.title() for x in section.split("_")])
writeln(title + " struct{")
if "order" in config[section]:
writeln('Order []string `json:"order"`')
if "meta" in config[section]:
writeln('Meta Metadata `json:"meta"`')
for setting in [
x for x in config[section].keys() if x != "order" and x != "meta"
]:
name = "".join([x.title() for x in setting.split("_")])
writeln(name + " struct{")
writeln('Name string `json:"name"`')
writeln('Required bool `json:"required"`')
writeln('Restart bool `json:"requires_restart"`')
writeln('Description string `json:"description"`')
writeln('Type string `json:"type"`')
dt = config[section][setting]["type"]
if dt == "select":
dt = "string"
writeln('Options []string `json:"options"`')
elif dt == "number":
dt = "int"
elif dt != "bool":
dt = "string"
writeln(f'Value {dt} `json:"value" cfg:"{setting}"`')
writeln("} " + f'`json:"{setting}" cfg:"{setting}"`')
writeln("} " + f'`json:"{section}"`')
writeln("}")

377
css/base.css Normal file
View File

@@ -0,0 +1,377 @@
@import "a17t.css";
@import "remixicon.css";
@import "modal.css";
@import "dark.css";
@import "tooltip.css";
@import "loader.css";
:root {
--border-width-default: 2px;
--border-width-2: 3px;
--border-width-4: 5px;
--border-width-8: 8px;
}
.light-theme {
--settings-section-button-filter: 90%;
}
.body {
background-color: #101010;
}
.page-container {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.page-container {
margin: 2%;
}
}
@media screen and (max-width: 750px) {
:root {
font-size: 0.9rem;
}
.table-responsive table {
min-width: 660px;
}
}
.tab-button {
font-size: 2rem;
}
.mb-half {
margin-bottom: 0.5rem;
}
.mb-1 {
margin-bottom: 1rem;
}
.mb-2 {
margin-bottom: 2rem;
}
.mt-half {
margin-top: 0.5rem;
}
.mt-1 {
margin-top: 1rem;
}
.ml-1 {
margin-left: 1rem;
}
.ml-half {
margin-left: 0.5rem;
}
.mr-1 {
margin-right: 1rem;
}
.pb-1 {
padding-bottom: 1rem;
}
.pl-1 {
padding-left: 1rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex {
display: flex;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
}
.row.baseline {
align-items: baseline;
}
.col {
flex: 1;
margin: 0.5rem;
}
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
}
.col {
flex: 45%;
}
}
.fr {
float: right;
}
.monospace {
background-color: inherit; /* so we can use a17t code blocks */
}
sup.\~critical, .text-critical {
color: var(--color-critical-normal-content);
}
.grey {
color: var(--color-neutral-500);
}
.aside.sm {
font-size: 0.8rem;
padding: 0.8rem;
}
.support.lg {
font-size: 1rem;
}
.badge.lg {
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: center;
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 */
}
.button.lg {
height: 2.5rem;
}
.submit {
border: none;
outline: none; /* remove browser styling on submit buttons */
}
.full-width { /* full width inputs */
box-sizing: border-box; /* TODO: maybe remove if we figure out input thing? */
width: 100%;
}
.center {
justify-content: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
.unfocused {
display: none;
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
}
.stealth-input {
font-size: 1rem;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
margin-left: 0.5rem;
margin-right: 1rem;
max-width: 75%;
}
.stealth-input-hidden {
border-style: none;
--fallback-box-shadow: none;
--field-hover-box-shadow: none;
--field-focus-box-shadow: none;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.settings-section-button {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: rgba(0,0,0,0);
}
.settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important;
}
.settings-section-button.selected {
background-color: var(--color-neutral-normal-fill);
--buton-filter-brightness: var(--settings-section-button-filter);
filter: brightness(var(--settings-section-button-filter)) !important;
}
.setting {
margin-bottom: 0.25rem;
}
.textarea {
resize: vertical;
}
.overflow {
overflow: visible;
}
.overflow-y {
overflow-y: visible;
}
select, textarea {
color: inherit;
border: 0 solid var(--color-neutral-300);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
input {
color: inherit;
border: 0 solid var(--color-neutral-300);
}
p.top {
margin-top: 0px;
}
.table-responsive {
overflow-x: auto;
}
#notification-box {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 16;
}
.dropdown {
padding-bottom: 0.5rem;
margin-bottom: -0.5rem;
}

87
css/dark.css Normal file
View File

@@ -0,0 +1,87 @@
.dark-theme {
--settings-section-button-filter: 110%;
--color-neutral-900: rgba(255, 255, 255, 0.87);
--color-neutral-800: rgba(255, 255, 255, 0.8);
--color-neutral-700: rgba(255, 255, 255, 0.73);
--color-neutral-600: rgba(255, 255, 255, 0.66);
--color-neutral-500: rgb(153, 153, 153);
--color-neutral-400: #383838;
--color-neutral-300: #303030;
--color-neutral-200: #292929;
--color-neutral-100: #242424;
--color-neutral-50: #202020;
--color-neutral-000: #101010;
--color-critical-900: #fef2f2;
--color-critical-800: #fee2e2;
--color-critical-700: #fecaca;
--color-critical-600: #fca5a5;
--color-critical-500: #f87171;
--color-critical-400: #ef4444;
--color-critical-300: #dc2626;
--color-critical-200: #b91c1c;
--color-critical-100: #991b1b;
--color-critical-50: #7f1d1d;
--color-critical-000: #441313;
--color-warning-900: #fffbeb;
--color-warning-800: #fef3c7;
--color-warning-700: #fde68a;
--color-warning-600: #fcd34d;
--color-warning-500: #fbbf24;
--color-warning-400: #f59e0b;
--color-warning-300: #d97706;
--color-warning-200: #b45309;
--color-warning-100: #92400e;
--color-warning-50: #783900;
--color-warning-000: #411e01;
--color-positive-900: #f0fdf4;
--color-positive-800: #dcfce7;
--color-positive-700: #bbf7d0;
--color-positive-600: #86efac;
--color-positive-500: #4ade80;
--color-positive-400: #22c55e;
--color-positive-300: #16a34a;
--color-positive-200: #15803d;
--color-positive-100: #166534;
--color-positive-50: #14532d;
--color-positive-000: #0f2e1b;
--color-urge-900: #e0ffff;
--color-urge-800: #c0fbff;
--color-urge-700: #a0f4ff;
--color-urge-600: #80e9ff;
--color-urge-500: #60dbfb;
--color-urge-400: #40cbf3;
--color-urge-300: #20b9e9;
--color-urge-200: #00a4dc; /* tab buttons */
--color-urge-100: #0054bc;
--color-urge-50: #00169a;
--color-urge-000: #050076;
--color-info-900: #f5f3ff;
--color-info-800: #ede9fe;
--color-info-700: #ddd6fe;
--color-info-600: #c4b5fd;
--color-info-500: #a78bfa;
--color-info-400: #8b5cf6;
--color-info-300: #7c3aed;
--color-info-200: #6d28d9;
--color-info-100: #5b21b6;
--color-info-50: #4c1d95;
--color-info-000: #240e44;
--color-neutral-normal-content: #ffffff;
}
.light-only {
display: none;
}
.dark-only {
display: initial;
}

40
css/loader.css Normal file
View File

@@ -0,0 +1,40 @@
.loader {
height: auto;
color: rgba(0, 0, 0, 0);
}
.loader .dot {
--diameter: 0.5rem;
--radius: calc(var(--diameter) / 2);
--deviation: 20%;
height: var(--diameter);
width: var(--diameter);
background-color: var(--color-content);
border-radius: 50%;
position: absolute;
left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
}
.loader.loader-sm .dot {
--deviation: 10%;
}
@keyframes osc {
25% {
left: calc(50% + var(--deviation) - var(--radius));
}
75% {
left: calc(50% - var(--deviation));
}
0%, 50%, 100% {
left: calc(50% - var(--radius));
}
/*
0%, 100% {
left: calc(50% - var(--deviation))
}
50% {
left: calc(50% + var(--deviation) - var(--radius));
}
*/
}

72
css/modal.css Normal file
View File

@@ -0,0 +1,72 @@
.modal {
display: none;
position: fixed;
z-index: 12;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,40%);
}
.modal-shown {
display: block;
}
@keyframes modal-hide {
from { opacity: 1; }
to { opacity: 0; }
}
.modal-hiding {
animation: modal-hide 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes modal-content-show {
from {
opacity: 0;
top: -6rem;
}
to {
opacity: 1;
top: 0;
}
}
.modal-content {
position: relative;
margin: 10% auto;
width: 30%;
}
.modal-content.wide {
width: 60%;
}
.modal-shown .modal-content {
animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@media screen and (max-width: 1000px) {
.modal-content.wide {
width: 75%;
}
}
@media screen and (max-width: 400px) {
.modal-content, .modal-content.wide {
width: 90%;
}
}
.modal-close {
float: right;
color: #aaa;
font-weight: normal;
}
.modal-close:hover,
.modal-close:focus {
filter: brightness(60%);
}

36
css/tooltip.css Normal file
View File

@@ -0,0 +1,36 @@
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .content {
visibility: hidden;
max-width: 10rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 0.5rem;
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.right .content {
left: 120%;
}
.tooltip.left .content {
right: 120%;
}
.tooltip .content.sm {
font-size: 0.8rem;
}
.tooltip:hover .content {
visibility: visible;
}

View File

@@ -4,7 +4,7 @@ import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type Repeater struct {
type repeater struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
@@ -12,8 +12,8 @@ type Repeater struct {
app *appContext
}
func NewRepeater(interval time.Duration, app *appContext) *Repeater {
return &Repeater{
func newRepeater(interval time.Duration, app *appContext) *repeater {
return &repeater{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
@@ -22,7 +22,7 @@ func NewRepeater(interval time.Duration, app *appContext) *Repeater {
}
}
func (rt *Repeater) Run() {
func (rt *repeater) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
@@ -42,7 +42,7 @@ func (rt *Repeater) Run() {
}
}
func (rt *Repeater) Shutdown() {
func (rt *repeater) shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel

View File

@@ -1,275 +0,0 @@
document.getElementById('page-1').scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'center' });
function checkAuthRadio() {
if (document.getElementById('manualAuthRadio').checked) {
document.getElementById('adminOnlyArea').style.display = 'none';
document.getElementById('manualAuthArea').style.display = '';
} else {
document.getElementById('manualAuthArea').style.display = 'none';
document.getElementById('adminOnlyArea').style.display = '';
};
};
var authRadios = ['manualAuthRadio', 'jfAuthRadio'];
for (var i = 0; i < authRadios.length; i++) {
document.getElementById(authRadios[i]).addEventListener('change', function() {
checkAuthRadio();
});
};
function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7';
if (document.getElementById('emailSMTPRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = '';
document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailMailgunRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = '';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailDisabledRadio').checked) {
document.getElementById('emailCommonArea').style.display = 'none';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('emailNextButton').href = '#page-8';
document.getElementById('valBackButton').href = '#page-4';
document.getElementById('notificationsEnabled').checked = false;
};
};
var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio'];
for (var i = 0; i < emailRadios.length; i++) {
document.getElementById(emailRadios[i]).addEventListener('change', function() {
checkEmailRadio();
});
};
function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) {
label.textContent = 'Use SSL/TLS';
} else {
label.textContent = 'Use STARTTLS';
};
};
document.getElementById('emailSSL_TLS').addEventListener('change', function() {
checkSSL();
});
function checkPwrEnabled() {
if (document.getElementById('pwrEnabled').checked) {
document.getElementById('pwrArea').style.display = '';
} else {
document.getElementById('pwrArea').style.display = 'none';
};
};
var pwrEnabled = document.getElementById('pwrEnabled');
pwrEnabled.addEventListener('change', function() {
checkPwrEnabled();
});
function checkInvEnabled() {
if (document.getElementById('invEnabled').checked) {
document.getElementById('invArea').style.display = '';
} else {
document.getElementById('invArea').style.display = 'none';
};
};
document.getElementById('invEnabled').addEventListener('change', function() {
checkInvEnabled();
});
function checkValEnabled() {
if (document.getElementById('valEnabled').checked) {
document.getElementById('valArea').style.display = '';
} else {
document.getElementById('valArea').style.display = 'none';
};
};
document.getElementById('valEnabled').addEventListener('change', function() {
checkValEnabled();
});
checkValEnabled();
checkInvEnabled();
checkSSL();
checkAuthRadio();
checkEmailRadio();
checkPwrEnabled();
var jfValid = false
document.getElementById('jfTestButton').onclick = function() {
var testButton = document.getElementById('jfTestButton');
var nextButton = document.getElementById('jfNextButton');
var jfData = {};
jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value;
let valid = true;
for (val in jfData) {
if (jfData[val] == "") {
valid = false;
}
}
if (!valid) {
if (!testButton.classList.contains('btn-danger')) {
testButton.classList.add('btn-danger');
testButton.textContent = 'Fill out fields above.';
setTimeout(function() {
if (testButton.classList.contains('btn-danger')) {
testButton.classList.remove('btn-danger');
testButton.textContent = 'Test';
}
}, 2000);
}
} else {
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var req = new XMLHttpRequest();
req.open("POST", "/jellyfin/test", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
};
};
};
req.send(JSON.stringify(jfData));
}
};
document.getElementById('submitButton').onclick = function() {
var submitButton = document.getElementById('submitButton');
submitButton.disabled = true;
submitButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Submitting...';
var config = {};
config['jellyfin'] = {};
config['ui'] = {};
config['password_validation'] = {};
config['email'] = {};
config['password_resets'] = {};
config['invite_emails'] = {};
config['mailgun'] = {};
config['smtp'] = {};
config['notifications'] = {};
// Page 2: Auth
if (document.getElementById('jfAuthRadio').checked) {
config['ui']['jellyfin_login'] = 'true';
if (document.getElementById('jfAuthAdminOnly').checked) {
config['ui']['admin_only'] = 'true';
} else {
config['ui']['admin_only'] = 'false'
};
} else {
config['ui']['username'] = document.getElementById('manualAuthUsername').value;
config['ui']['password'] = document.getElementById('manualAuthPassword').value;
config['ui']['email'] = document.getElementById('manualAuthEmail').value;
};
// Page 3: Connect to jellyfin
config['jellyfin']['server'] = document.getElementById('jfHost').value;
let publicAddress = document.getElementById('jfPublicHost').value;
if (publicAddress != "") {
config['jellyfin']['public_server'] = publicAddress;
}
config['jellyfin']['username'] = document.getElementById('jfUser').value;
config['jellyfin']['password'] = document.getElementById('jfPassword').value;
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)
if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false';
config['notifications']['enabled'] = 'false';
} else {
if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls';
} else {
config['smtp']['encryption'] = 'starttls';
};
config['email']['method'] = 'smtp';
config['smtp']['server'] = document.getElementById('emailSMTPServer').value;
config['smtp']['port'] = document.getElementById('emailSMTPPort').value;
config['smtp']['password'] = document.getElementById('emailSMTPPassword').value;
config['email']['address'] = document.getElementById('emailSMTPAddress').value;
} else {
config['email']['method'] = 'mailgun';
config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value;
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
config['email']['address'] = document.getElementById('emailMailgunAddress').value;
};
config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString();
// Page 5: Email formatting
config['email']['from'] = document.getElementById('emailSender').value;
config['email']['date_format'] = document.getElementById('emailDateFormat').value;
if (document.getElementById('email24hTimeRadio').checked) {
config['email']['use_24h'] = 'true';
} else {
config['email']['use_24h'] = 'false';
};
config['email']['message'] = document.getElementById('emailMessage').value;
// Page 6: Password Resets
if (document.getElementById('pwrEnabled').checked) {
config['password_resets']['enabled'] = 'true';
config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value;
config['password_resets']['subject'] = document.getElementById('pwrSubject').value;
} else {
config['password_resets']['enabled'] = 'false';
};
// Page 7: Invite Emails
if (document.getElementById('invEnabled').checked) {
config['invite_emails']['enabled'] = 'true';
config['invite_emails']['url_base'] = document.getElementById('invURLBase').value;
config['invite_emails']['subject'] = document.getElementById('invSubject').value;
} else {
config['invite_emails']['enabled'] = 'false';
};
};
// Page 8: Password Validation
if (document.getElementById('valEnabled').checked) {
config['password_validation']['enabled'] = 'true';
config['password_validation']['min_length'] = document.getElementById('valLength').value;
config['password_validation']['upper'] = document.getElementById('valUpper').value;
config['password_validation']['lower'] = document.getElementById('valLower').value;
config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else {
config['password_validation']['enabled'] = 'false';
};
// Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value;
// Send it
config["restart-program"] = true;
var req = new XMLHttpRequest();
req.open("POST", "/config", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
submitButton.disabled = false;
submitButton.className = '';
submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success';
};
};
req.send(JSON.stringify(config));
};

View File

@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<title>404</title>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ if not .bs5 }}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{{ if .bs5 }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{{ else }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ .contactMessage }}
</p>
</div>
</body>
</html>

View File

@@ -1,494 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<!-- Bootstrap CSS -->
<script>
// To grab theme preference
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let c of ca) {
while(c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
{{ if .bs5 }}
window.bsVersion = 5;
{{ else }}
window.bsVersion = 4;
{{ end }}
window.cssFile = "{{ .cssFile }}";
var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) {
cssFile = cssCookie;
} else if (cssCookie.includes('bs')) {
if (cssCookie.includes('jf')) {
cssFile = 'bs' + bsVersion + '-jf.css';
} else {
cssFile = 'bs' + bsVersion + '.css';
}
document.cookie = 'css=' + cssFile;
}
css.setAttribute('href', cssFile);
document.head.appendChild(css);
</script>
{{ if not .bs5 }}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{{ if .bs5 }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{{ else }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<title>Admin</title>
</head>
<body class="smooth-transition">
<div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginTitle">Login</h5>
</div>
<div class="modal-body" id="formBody">
<form action="#" method="POST" id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" id="loginSubmit" class="btn btn-primary" form="loginForm">Login</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="users" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="usersTitle">Users</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush" id="userList">
</ul>
</div>
<div class="modal-footer" id="userFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="userDefaults" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultsTitle"></h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<p id="userDefaultsDescription"></p>
<div class="mb-3" id="defaultsSourceSection">
<label for="defaultsSource" class="form-label">Use settings from:</label>
<select class="form-select" id="defaultsSource" aria-label="User settings source">
<option value="profile" selected>Profile</option>
<option value="fromUser">Source from existing user</option>
</select>
</div>
<div class="mb-3 unfocused" id="profileSelectBox">
<label for="profileSelect" class="form-label">Profile</label>
<select class="form-select" id="profileSelect" aria-label="Profile to apply">
</select>
</div>
<div class="mb-3 unfocused" id="newProfileBox">
<label for="newProfileName" class="form-label">Name</label>
<input type="text" class="form-control" id="newProfileName" aria-describedby="Profile Name">
</div>
<div id="defaultUserRadiosBox">
<label for="defaultUserRadios" class="form-label">Get settings from</label>
<div id="defaultUserRadios"></div>
</div>
<div class="form-check" style="margin-top: 1rem;">
<input class="form-check-input" type="checkbox" value="" id="storeDefaultHomescreen" checked>
<label class="form-check-label" for="storeDefaultHomescreen" id="storeHomescreenLabel"></label>
</div>
</div>
<div class="modal-footer" id="defaultsFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="storeDefaults">Submit</button>
</div>
</div>
</div>
</div>
{{ if .ombiEnabled }}
<div class="modal fade" id="ombiDefaults" role="dialog" aria-labelledby="Ombi Users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ombiTitle">Ombi user defaults</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<p>Create an Ombi user and configure it to your liking, then choose it from below to store the settings and permissions as a template for all new users.</p>
<div id="ombiUserRadios"></div>
</div>
<div class="modal-footer" id="ombiFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="storeOmbiDefaults">Submit</button>
</div>
</div>
</div>
</div>
{{ end }}
<div class="modal fade" id="restartModal" role="dialog" aria-labelledby="Restart Warning" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Warning</h5>
</div>
<div class="modal-body">
<p>A restart is needed to apply some settings. Restart now, later, or cancel?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal" id="restartModalCancel">Cancel</button>
<button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button>
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply &amp; Restart</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="refreshModal" role="dialog" aria-labelledby="Refresh page notice" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Settings applied.</h5>
</div>
<div class="modal-body">
<p>Refresh the page in a few seconds.</p>
</div>
</div>
</div>
</div>
<div class="modal fade" id="aboutModal" role="dialog" aria-labelledby="About jfa-go" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">About</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<img src="banner.svg" alt="jfa-go banner">
<p><a href="https://github.com/hrfee/jfa-go"><i class="fa fa-github"></i> jfa-go</a></p>
<p>Version <i class="text-monospace">{{ .version }}</i></p>
<p>Commit <i class="text-monospace">{{ .commit }}</i></p>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deleteModal" role="dialog" aria-labelledby="Account deletion" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalTitle"></h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="deleteModalNotify">
<label class="form-check-label" for="deleteModalNotify" id="deleteModalNotifyLabel">Notify users of account deletion</label>
</div>
<div class="mb-3 unfocused" id="deleteModalReasonBox">
<label for="deleteModalReason" class="form-label">Reason for deletion</label>
<textarea class="form-control" id="deleteModalReason" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="deleteModalSend">Delete</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="newUserModal" role="dialog" aria-labelledby="Create new user" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create a user</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newUserEmail" class="form-label">Email address</label>
<input type="email" class="form-control" id="newUserEmail" aria-describedby="Email address">
</div>
{{ if .username }}
<div class="mb-3">
<label for="newUserName" class="form-label">Username</label>
<input type="text" class="form-control" id="newUserName" aria-describedby="Username">
</div>
{{ end }}
<div class="mb-3">
<label for="newUserPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="newUserPassword" aria-describedby="Password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="newUserCreate">Create</button>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<ul class="nav nav-pills" style="margin-bottom: 2rem;">
<li class="nav-item">
<h2><a id="invitesTabButton" class="nl nav-link active">Invites</a></h2>
</li>
<li class="nav-item">
<h2><a id="accountsTabButton" class="nl nav-link">Accounts</a></h2>
</li>
<li class="nav-item">
<h2><a id="settingsTabButton" class="nl nav-link">Settings</a></h2>
</li>
</ul>
<div class="btn-group" role="group" id="headerButtons">
<button type="button" class="btn btn-danger unfocused" id="logoutButton">
Logout <i class="fa fa-sign-out"></i>
</button>
</div>
<div id="invitesTab">
<div class="card mb-3 tabGroup">
<div class="card-header">Current Invites</div>
<ul class="list-group list-group-flush" id="invites">
</ul>
</div>
<div class="linkForm">
<div class="card mb-3">
<div class="card-header">Generate Invite</div>
<div class="card-body">
<form action="#" method="POST" id="inviteForm" class="container">
<div class="row align-items-start">
<div class="col-sm">
<div class="form-group">
<label for="days">Days</label>
<select class="form-control form-select" id="days" name="days">
</select>
</div>
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control form-select" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control form-select" id="minutes" name="minutes">
</select>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="multiUseCount">
Multiple uses
</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
</div>
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
</div>
</div>
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
No use limit
</label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label for="inviteProfile">Account creation profile</label>
<select class="form-control form-select" id="inviteProfile" name="profile">
</select>
</div>
{{ if .email_enabled }}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{{ end }}
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex float-right">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
Generate
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="accountsTab" class="unfocused">
<div class="card mb-3 tabGroup">
<div class="card-header d-flex" style="align-items: center;">
<div>Accounts</div>
<div class="ml-auto">
<button type="button" class="btn btn-secondary" id="accountsTabAddUser">Add User</button>
<button type="button" class="btn btn-primary unfocused" id="accountsTabSetDefaults">Modify Settings</button>
<button type="button" class="btn btn-danger unfocused" id="accountsTabDelete"></button>
</div>
</div>
<div class="card-body table-responsive">
<table class="table table-hover table-striped table-borderless">
<thead>
<tr>
{{ if .bs5 }}
<th scope="col"><input class="form-check-input" type="checkbox" value="" id="selectAll"></th>
{{ else }}
<th scope="col"><input type="checkbox" value="" id="selectAll"></th>
{{ end }}
<th scope="col">Username</th>
<th scope="col">Email Address</th>
<th scope="col">Last Active</th>
</tr>
</thead>
<tbody id="accountsList">
</tbody>
</table>
</div>
</div>
</div>
<div id="settingsTab" class="unfocused mb-3 tabGroup card">
<div class="card-header d-flex" style="align-items: center;">
<div>Settings</div>
<div class="ml-auto">
<button type="button" class="btn btn-primary" id="settingsSave">Save</button>
</div>
</div>
<div class="container card-body">
<div class="row">
<div class="col-sm">
<div class="" id="settingsLeft">
<ul class="list-group list-group-flush" style="margin-bottom: 1rem;">
<p>Note: <sup class="text-danger">*</sup> Indicates required field, <sup class="text-danger">R</sup> Indicates changes require a restart.</p>
<button type="button" class="list-group-item list-group-item-action static" id="openAbout">
About <i class="fa fa-info-circle settingIcon"></i>
</button>
<button type="button" class="list-group-item list-group-item-action" id="profiles_button">
User Profiles <i class="fa fa-user settingIcon"></i>
</button>
{{ if .ombiEnabled }}
<button type="button" class="list-group-item list-group-item-action static" id="openOmbiDefaults" onclick="window.openOmbiDefaults()">
Ombi User Defaults <i class="fa fa-chain-broken settingIcon"></i>
</button>
{{ end }}
</ul>
<div class="list-group list-group-flush" id="settingsSections">
</div>
</div>
</div>
<div class="col">
<div class="" id="settingsContent">
<div id="profiles" class="unfocused">
<div class="card card-body">
<p>Profiles are applied to users when they create an account. They include things like access rights and homescreen layout. You can create them here.</p>
<table class="table table-sm table-striped table-borderless">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Default</th>
<th scope="col">From</th>
<th scope="col">Admin?</th>
<th scope="col">Libraries</th>
<th scope="col"><button class="btn btn-outline-primary" onclick="createProfile()">Create</button></th>
</tr>
</thead>
<tbody id="profileList">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="contactBox">
<p>{{ .contactMessage }}</p>
</div>
</div>
<script>
window.bs5 = {{ .bs5 }};
window.availableProfiles = [];
{{ if .notifications }}
window.notifications_enabled = true;
{{ else }}
window.notifications_enabled = false;
{{ end }}
</script>
<script src="admin.js" type="module"></script>
<script src="invites.js" type="module"></script>
{{ if .ombiEnabled }}
<script src="ombi.js" type="module"></script>
{{ end }}
</body>
</html>

View File

@@ -1,127 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ if not .settings.bs5 }}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{{ if .settings.bs5 }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{{ else }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.pageContainer {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
.contactBox {
color: grey;
}
#container {
margin-top: 5%;
margin-bottom: 5%;
}
</style>
<title>{{ .lang.pageTitle }}</title>
</head>
<body>
<div class="modal fade" id="successBox" tabindex="-1" role="dialog" aria-labelledby="successBox" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="successHeader">{{ .lang.successHeader }}</h5>
</div>
<div class="modal-body" id="successBody">
<p>{{ .successMessage }}</p>
</div>
<div class="modal-footer">
<a href="{{ .jfLink }}" class="btn btn-primary">{{ .lang.successContinueButton }}</a>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<h1>
{{ .lang.createAccountHeader }}
</h1>
<p>{{ .helpMessage }}</p>
<p class="contactBox">{{ .contactMessage }}</p>
<div class="container" id="container">
<div class="row" id="cardContainer">
<div class="col-sm">
<div class="card mb-3">
<div class="card-header">{{ .lang.accountDetails }}</div>
<div class="card-body">
<form action="#" method="POST" id="accountForm">
<div class="form-group">
<label for="inputEmail">{{ .lang.emailAddress }}</label>
<input type="email" class="form-control" id="{{ if .settings.username }}inputEmail{{ else }}inputUsername{{ end }}" name="{{ if .settings.username }}email{{ else }}username{{ end }}" placeholder="{{ .lang.emailAddress }}" value="{{ .email }}" required>
</div>
{{ if .settings.username }}
<div class="form-group">
<label for="inputUsername">{{ .lang.username }}</label>
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="{{ .lang.username }}" required>
</div>
{{ end }}
<div class="form-group">
<label for="inputPassword">{{ .lang.password }}</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="{{ .lang.password }}" required>
</div>
<div class="form-group">
<label for="inputPassword">{{ .lang.reEnterPassword }}</label>
<input type="password" class="form-control" id="reInputPassword" onkeyup="window.checkPassword()" placeholder="{{ .lang.password }}" required>
</div>
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox" style="margin-top: 1rem;">
<button type="submit" class="btn btn-outline-primary" id="submitButton">
<span id="createAccount">{{ .lang.createAccountButton }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
{{ if .validate }}
<div class="col-sm" id="requirementBox">
<div class="card mb-3 requirementBox">
<div class="card-header">{{ .lang.passwordRequirementsHeader }}</div>
<div class="card-body">
<ul class="list-group">
{{ range $key, $value := .requirements }}
<li id="{{ $key }}" min="{{ $value }}" class="list-group-item list-group-item-danger">
<div></div>
</li>
{{ end }}
</ul>
</div>
</div>
</div>
{{ end }}
</div>
</div>
</div>
<script>
window.validationStrings = {{ .lang.validationStrings }};
</script>
{{ template "form-base" . }}
</body>
</html>

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Invalid Code</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ if not .bs5 }}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{{ if .bs5 }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{{ else }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ .contactMessage }}</p>
</div>
</body>
</html>

View File

@@ -73,6 +73,8 @@ func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error {
// Emailer contains the email sender, email content, and methods to construct message content.
type Emailer struct {
fromAddr, fromName string
lang *EmailLang
cLang string
sender emailClient
}
@@ -108,6 +110,8 @@ func NewEmailer(app *appContext) *Emailer {
emailer := &Emailer{
fromAddr: app.config.Section("email").Key("address").String(),
fromName: app.config.Section("email").Key("from").String(),
lang: &(app.storage.lang.Email),
cLang: app.storage.lang.chosenEmailLang,
}
method := app.config.Section("email").Key("method").String()
if method == "smtp" {
@@ -153,8 +157,9 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
}
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
lang := emailer.cLang
email := &Email{
subject: app.config.Section("invite_emails").Key("subject").String(),
subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.get(lang, "inviteEmail", "title")),
}
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
@@ -170,11 +175,13 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"expiry_date": d,
"expiry_time": t,
"expires_in": expiresIn,
"invite_link": inviteLink,
"message": message,
"hello": emailer.lang.get(lang, "inviteEmail", "hello"),
"youHaveBeenInvited": emailer.lang.get(lang, "inviteEmail", "youHaveBeenInvited"),
"toJoin": emailer.lang.get(lang, "inviteEmail", "toJoin"),
"inviteExpiry": emailer.lang.format(lang, "inviteEmail", "inviteExpiry", d, t, expiresIn),
"linkButton": emailer.lang.get(lang, "inviteEmail", "linkButton"),
"invite_link": inviteLink,
"message": message,
})
if err != nil {
return nil, err
@@ -189,8 +196,9 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
}
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext) (*Email, error) {
lang := emailer.cLang
email := &Email{
subject: "Notice: Invite expired",
subject: emailer.lang.get(lang, "inviteExpiry", "title"),
}
expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} {
@@ -201,8 +209,9 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"code": code,
"expiry": expiry,
"inviteExpired": emailer.lang.get(lang, "inviteExpiry", "inviteExpired"),
"expiredAt": emailer.lang.format(lang, "inviteExpiry", "expiredAt", "\""+code+"\"", expiry),
"notificationNotice": emailer.lang.get(lang, "inviteExpiry", "notificationNotice"),
})
if err != nil {
return nil, err
@@ -217,8 +226,9 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
}
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) (*Email, error) {
lang := emailer.cLang
email := &Email{
subject: "Notice: User created",
subject: emailer.lang.get(lang, "userCreated", "title"),
}
created := app.formatDatetime(invite.Created)
var tplAddress string
@@ -235,10 +245,14 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"code": code,
"username": username,
"address": tplAddress,
"time": created,
"aUserWasCreated": emailer.lang.format(lang, "userCreated", "aUserWasCreated", "\""+code+"\""),
"name": emailer.lang.get(lang, "userCreated", "name"),
"address": emailer.lang.get(lang, "userCreated", "emailAddress"),
"time": emailer.lang.get(lang, "userCreated", "time"),
"nameVal": username,
"addressVal": tplAddress,
"timeVal": created,
"notificationNotice": emailer.lang.get(lang, "userCreated", "notificationNotice"),
})
if err != nil {
return nil, err
@@ -252,9 +266,10 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
return email, nil
}
func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error) {
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) {
lang := emailer.cLang
email := &Email{
subject: app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin"),
subject: emailer.lang.get(lang, "passwordReset", "title"),
}
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
@@ -266,12 +281,14 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"username": pwr.Username,
"expiry_date": d,
"expiry_time": t,
"expires_in": expiresIn,
"pin": pwr.Pin,
"message": message,
"helloUser": emailer.lang.format(lang, "passwordReset", "helloUser", pwr.Username),
"someoneHasRequestedReset": emailer.lang.get(lang, "passwordReset", "someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.get(lang, "passwordReset", "ifItWasYou"),
"codeExpiry": emailer.lang.format(lang, "passwordReset", "codeExpiry", d, t, expiresIn),
"ifItWasNotYou": emailer.lang.get(lang, "passwordReset", "ifItWasNotYou"),
"pin": emailer.lang.get(lang, "passwordReset", "pin"),
"pinVal": pwr.Pin,
"message": message,
})
if err != nil {
return nil, err
@@ -286,8 +303,9 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
}
func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
lang := emailer.cLang
email := &Email{
subject: app.config.Section("deletion").Key("subject").MustString("Your account was deleted - Jellyfin"),
subject: emailer.lang.get(lang, "userDeleted", "title"),
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("deletion").Key("email_" + key).String()
@@ -297,7 +315,9 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"reason": reason,
"yourAccountWasDeleted": emailer.lang.get(lang, "userDeleted", "yourAccountWasDeleted"),
"reason": emailer.lang.get(lang, "userDeleted", "reason"),
"reasonVal": reason,
})
if err != nil {
return nil, err

View File

@@ -1,5 +0,0 @@
#!/usr/bin/bash
# set +e
# npx tsc -p ts/
# set -e
npx esbuild ts/* --outdir=data/static --minify

17
go.mod
View File

@@ -4,7 +4,7 @@ go 1.14
replace github.com/hrfee/jfa-go/docs => ./docs
replace github.com/hrfee/jfa-go/jfapi => ./jfapi
replace github.com/hrfee/jfa-go/mediabrowser => ./mediabrowser
replace github.com/hrfee/jfa-go/common => ./common
@@ -19,13 +19,14 @@ require (
github.com/gin-gonic/gin v1.6.3
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/spec v0.19.13 // indirect
github.com/go-openapi/spec v0.20.0 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/gofrs/uuid v3.3.0+incompatible // indirect
github.com/golang/protobuf v1.4.3
github.com/google/uuid v1.1.2 // indirect
github.com/hrfee/jfa-go/common v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/jfapi v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/mediabrowser v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible
github.com/json-iterator/go v1.1.10 // indirect
@@ -41,15 +42,13 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
github.com/swaggo/gin-swagger v1.3.0
github.com/swaggo/swag v1.6.9 // indirect
github.com/swaggo/swag v1.7.0 // indirect
github.com/ugorji/go v1.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba // indirect
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
golang.org/x/text v0.3.4 // indirect
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b // indirect
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0
gopkg.in/yaml.v2 v2.3.0 // indirect
)

28
go.sum
View File

@@ -15,6 +15,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
@@ -66,6 +67,8 @@ github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+j
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4 h1:ixzUSnHTd6hCemgtAJgluaTSGYpLNpJY4mA2DIkdOAo=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
@@ -77,6 +80,10 @@ github.com/go-openapi/spec v0.19.12 h1:OO9WrvhDwtiMY/Opr1j1iFZzirI3JW4/bxNFRcntA
github.com/go-openapi/spec v0.19.12/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.19.13 h1:AcZVcWsrfW7LqyHKVbTZYpFF7jQcMxmAsWrw2p/b9ew=
github.com/go-openapi/spec v0.19.13/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.20.0 h1:HGLc8AJ7ynOxwv0Lq4TsnwLsWMawHAYiJIFzbcML86I=
github.com/go-openapi/spec v0.20.0/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
@@ -87,6 +94,8 @@ github.com/go-openapi/swag v0.19.10 h1:A1SWXruroGP15P1sOiegIPbaKio+G9N5TwWTFaVPm
github.com/go-openapi/swag v0.19.10/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.12 h1:Bc0bnY2c3AoF7Gc+IMIAQQsD8fLHjHpc19wXvYuayQI=
github.com/go-openapi/swag v0.19.12/go.mod h1:eFdyEBkTdoAf/9RXBvj4cr1nH7GD8Kzo5HTt47gr72M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
@@ -130,6 +139,8 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hrfee/jfa-go/jfapi v0.0.0-20210109010027-4aae65518089 h1:WRk+JAywI8V4u+PBQpdvXBX73yCZxgnLwyIiX7xL+Xc=
github.com/hrfee/jfa-go/jfapi v0.0.0-20210109010027-4aae65518089/go.mod h1:Al1Rd1JGtpS+3KnK8t7+J0CZVDbT86QJrXHR6kZijds=
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e h1:OGunVjqY7y4U4laftpEHv+mvZBlr7UGimJXKEGQtg48=
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e/go.mod h1:Fy2gCFfZhay8jplf/Csj6cyH/oshQTkLQYZbKkcV+SY=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI=
@@ -151,6 +162,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.9 h1:heVeuAYtevIQVYkGj6A41dtfT91LrvFG220lavpWhrU=
@@ -200,6 +212,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLD
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858 h1:lgbJiJQx8bXo+eM88AFdd0VxUvaTLzCBXpK+H9poJ+Y=
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858/go.mod h1:y02HeaN0visd95W6cEX2NXDv5sCwyqfzucWTdDGEwYY=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@@ -234,6 +247,8 @@ github.com/swaggo/swag v1.6.8 h1:z3ZNcpJs/NLMpZcKqXUsBELmmY2Ocy09JXKx5gu3L4M=
github.com/swaggo/swag v1.6.8/go.mod h1:a0IpNeMfGidNOcm2TsqODUh9JHdHu3kxDA0UlGbBKjI=
github.com/swaggo/swag v1.6.9 h1:BukKRwZjnEcUxQt7Xgfrt9fpav0hiWw9YimdNO9wssw=
github.com/swaggo/swag v1.6.9/go.mod h1:a0IpNeMfGidNOcm2TsqODUh9JHdHu3kxDA0UlGbBKjI=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
@@ -310,6 +325,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTi
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -340,6 +357,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba h1:xmhUJGQGbxlod18iJGqVEp9cHIPLl7QiX2aA3to708s=
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -376,6 +396,10 @@ golang.org/x/tools v0.0.0-20201113202037-1643af1435f3 h1:7R7+wzd5VuLvCNyHZ/MG511
golang.org/x/tools v0.0.0-20201113202037-1643af1435f3/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b h1:Ych5r0Z6MLML1fgf5hTg9p5bV56Xqx9xv9hLgMBATWs=
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee h1:5xKxdl/RhlelmSPaxyVeq5PYSmJ4H14yeQT58qP1F6o=
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -401,6 +425,7 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
@@ -420,6 +445,9 @@ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

16
html/404.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>404 - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<h1 class="heading">Page not found.</h1>
<p class="content">
{{ .contactMessage }}
</p>
</div>
</body>
</html>

294
html/admin.html Normal file
View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .email_enabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
</script>
{{ template "header.html" . }}
<title>Admin - jfa-go</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-login" class="modal">
<form class="modal-content card" id="form-login" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral !high mb-1" placeholder="{{ .strings.password }}" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.login }}</span>
</label>
</form>
</div>
<div id="modal-add-user" class="modal">
<form class="modal-content card" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}">
<input type="password" class="field input ~neutral !high mb-1" placeholder="{{ .strings.password }}" id="add-user-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.create }}</span>
</label>
</form>
</div>
<div id="modal-about" class="modal">
<div class="modal-content content card">
<span class="heading">{{ .strings.aboutProgram }} <span class="modal-close">&times;</span></span>
<img src="/banner.svg" class="mt-1" alt="jfa-go banner">
<p><i class="icon ri-github-fill"></i><a href="https://github.com/hrfee/jfa-go">jfa-go</a></p>
<p>{{ .strings.version }} <span class="code monospace">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="code monospace">{{ .commit }}</span></p>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="modal-content card" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.modifySettingsDescription }}</p>
<div class="flex-row mb-1">
<label class="flex-row-group mr-1">
<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="flex-row-group ml-1">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral !normal supra full-width center">{{ .strings.user }}</span>
</label>
</div>
<div class="select ~neutral !normal mb-1">
<select id="modify-user-profiles"></select>
</div>
<div class="select ~neutral !normal mb-1 unfocused">
<select id="modify-user-users"></select>
</div>
<label class="switch mb-1">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.apply }}</span>
</label>
</form>
</div>
<div id="modal-delete-user" class="modal">
<form class="modal-content card" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-half">
<label class="switch mb-1">
<input type="checkbox" id="delete-user-notify" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label>
<textarea id="textarea-delete-user" class="textarea full-width ~neutral !normal mb-1" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
<label>
<input type="submit" class="unfocused">
<span class="button ~critical !normal full-width center supra submit">{{ .strings.delete }}</span>
</label>
</div>
</form>
</div>
<div id="modal-restart" class="modal">
<div class="modal-content card ~critical !low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content pb-1">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="fr">
<span class="button ~info !normal mb-half" id="settings-apply-no-restart">{{ .strings.settingsApplyRestartLater }}</span>
<span class="button ~critical !normal" id="settings-apply-restart">{{ .strings.settingsApplyRestartNow }}</span>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="modal-content card ~neutral !normal">
<span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<div id="modal-ombi-defaults" class="modal">
<form class="modal-content card" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiUserDefaults }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral !normal mb-1">
<select></select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="modal-user-profiles" class="modal">
<div class="modal-content wide card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="support lg">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.userProfilesIsDefault }}</th>
<th>{{ .strings.from }}</th>
<th>{{ .strings.userProfilesLibraries }}</th>
<th><span class="button ~neutral !high" id="button-profile-create">{{ .strings.create }}</span></th>
</tr>
</thead>
<tbody id="table-profiles"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-add-profile" class="modal">
<form class="modal-content card" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.addProfileDescription }}</p>
<label>
<span class="supra">{{ .strings.addProfileNameOf }} </span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.name }}" id="add-profile-name">
<label>
<span class="supra">{{ .strings.user }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="add-profile-user"></select>
</div>
</label>
<label class="switch mb-1">
<input type="checkbox" id="add-profile-homescreen" checked>
<span>{{ .strings.addProfileStoreHomescreenLayout }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.create }}</span>
</label>
</form>
</div>
<div id="notification-box"></div>
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-1 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral !low" id="lang-list">
</div>
</div>
</span>
<div class="page-container">
<div class="mb-1">
<header class="flex flex-wrap items-center justify-between">
<div class="text-neutral-700">
<span id="button-tab-invites" class="tab-button portal">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="tab-button portal">{{ .strings.accounts }}</span>
<span id="button-tab-settings" class="tab-button portal">{{ .strings.settings }}</span>
</div>
</header>
</div>
<div class="mb-1">
<div class="text-neutral-700">
<span class="button ~critical !normal mb-1 unfocused" id="logout-button">{{ .strings.logout }}</span>
<span id="button-theme" class="button ~neutral !normal mb-1">{{ .strings.theme }}</span>
</div>
</div>
<div id="tab-invites">
<div class="card ~neutral !low invites mb-1">
<span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div>
</div>
<div class="card ~neutral !low">
<span class="heading">{{ .strings.create }}</span>
<div class="row" id="create-inv">
<div class="card ~neutral !normal col">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-minutes">
<option>0</option>
</select>
</div>
</div>
<div class="card ~neutral !normal col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex-expand mb-1 mt-half">
<input type="number" min="0" id="create-uses" class="input ~neutral !normal mr-1" value=1>
<label for="create-inf-uses" class="button ~neutral !normal">
<span></span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused" 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 !normal mb-1 mt-half">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-1 mt-half">
<input type="email" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com">
<label for="create-send-to-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
<span class="button ~urge !normal supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div class="card ~neutral !low accounts mb-1">
<span class="heading">{{ .strings.accounts }}</span>
<div class="fr">
<span class="button ~neutral !normal" id="accounts-add-user">{{ .quantityStrings.addUser.singular }}</span>
<span class="button ~urge !normal" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="button ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.singular }}</span>
</div>
<div class="card ~neutral !normal accounts-header table-responsive mt-half">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
<th>{{ .strings.username }}</th>
<th>{{ .strings.emailAddress }}</th>
<th>{{ .strings.lastActiveTime }}</th>
</tr>
</thead>
<tbody id="accounts-list"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card ~neutral !low settings overflow">
<span class="heading">{{ .strings.settings }}</span>
<div class="fr">
<span class="button ~neutral !normal unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
</div>
<div class="row">
<div class="card ~neutral !normal col" id="settings-sidebar">
<aside class="aside sm ~info mb-half" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info">R</span> indicates changes require a restart.</aside>
<span class="button ~neutral !low settings-section-button mb-half" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-half"></i></span></span>
<span class="button ~neutral !low settings-section-button mb-half" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-half"></i></span></span>
</div>
<div class="card ~neutral !normal col overflow" id="settings-panel"></div>
</div>
</div>
</div>
</div>
<script src="js/admin.js" type="module"></script>
</body>
</html>

View File

@@ -1,9 +1,10 @@
{{ define "form-base" }}
<script>
window.bs5 = {{ .settings.bs5 }};
window.usernameEnabled = {{ .settings.username }};
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .lang.validationStrings }});
window.invalidPassword = "{{ .lang.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}";
</script>
<script src="form.js" type="module"></script>
<script src="js/form.js" type="module"></script>
{{ end }}

79
html/form.html Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>{{ .lang.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .lang.successHeader }}</span>
<p class="content mb-1">{{ .successMessage }}</p>
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .lang.successContinueButton }}</a>
</div>
</div>
<div id="notification-box"></div>
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-1 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral !low" id="lang-list">
</div>
</div>
</span>
<div class="page-container">
<div class="card ~neutral !low">
<div class="row baseline">
<span class="col heading">{{ .lang.createAccountHeader }}</span>
<span class="col subheading"> {{ .helpMessage }}</span>
</div>
<div class="row">
<div class="col">
<form class="card ~neutral !normal" id="form-create" href="">
<label class="label supra">
{{ .lang.username }}
<input type="text" class="input ~neutral !high mt-half mb-1" placeholder="{{ .lang.username }}" id="create-username" aria-label="{{ .lang.username }}">
</label>
<label class="label supra" for="create-email">{{ .lang.emailAddress }}</label>
<input type="email" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.emailAddress }}" id="create-email" aria-label="{{ .lang.emailAddress }}" value="{{ .email }}">
<label class="label supra" for="create-password">{{ .lang.password }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.password }}" id="create-password" aria-label="{{ .lang.password }}">
<label class="label supra" for="create-reenter-password">{{ .lang.reEnterPassword }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.password }}" id="create-reenter-password" aria-label="{{ .lang.reEnterPassword }}">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .lang.createAccountButton }}</span>
</label>
</form>
</div>
<div class="col">
<div class="card ~neutral !normal">
<span class="label supra" for="inv-uses">{{ .lang.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
</li>
{{ end }}
</ul>
</div>
{{ if .contactMessage }}
<aside class="col aside sm ~info">{{ .contactMessage }}</aside>
{{ end }}
</div>
</div>
</div>
</div>
<script>
window.validationStrings = {{ .lang.validationStrings }};
</script>
{{ template "form-base" . }}
</body>
</html>

11
html/header.html Normal file
View File

@@ -0,0 +1,11 @@
<meta charset="utf-8">
<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.">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">

17
html/invalidCode.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>Invalid Code - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<h1 class="heading">Invalid invite code.</h1>
<p class="content">The code above was either incorrect, or has expired.</p>
<p class="content">
{{ .contactMessage }}
</p>
</div>
</body>
</html>

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="bs5-jf.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.min.js" integrity="sha384-t6I8D5dJmMXjCsRLhSzCltuhNZg6P10kE0m0nAncLUjH6GeYLhRU1zfLoW3QNQDF" crossorigin="anonymous"></script>
<style>
.card-body {
width: 100%;
@@ -31,7 +31,7 @@
margin: 10%;
}
</style>
<title>Setup - Jellyfin Accounts</title>
<title>Setup - jfa-go</title>
</head>
<body>
<div class="pageContainer">
@@ -369,6 +369,6 @@
</div>
</div>
</div>
<script src="setup.js"></script>
<script src="js/setup.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

BIN
images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

3
images/gengif.txt Normal file
View File

@@ -0,0 +1,3 @@
Commands for making GIF:
ffmpeg -i demo.mkv -vf "palettegen" videoPalette.png
ffmpeg -i demo.mkv -i videoPalette.png -lavfi "fps=25 [x]; [x][1:v] paletteuse" -y demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1 +0,0 @@
../data/static/banner.svg

View File

@@ -1,11 +0,0 @@
# Systemd service file for jfa-go. Install to ~/.config/systemd/user.
[Unit]
Description=A web app for managing users on Jellyfin
[Service]
# Modify this to the path to your executable, if necessary.
ExecStart=/opt/jfa-go/jfa-go
[Install]
WantedBy=default.target

View File

@@ -1,7 +0,0 @@
module github.com/hrfee/jfa-go/jfapi
go 1.15
replace github.com/hrfee/jfa-go/common => ../common
require github.com/hrfee/jfa-go/common v0.0.0-00010101000000-000000000000

View File

@@ -1,364 +0,0 @@
package jfapi
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
)
type serverInfo struct {
LocalAddress string `json:"LocalAddress"`
Name string `json:"ServerName"`
Version string `json:"Version"`
OS string `json:"OperatingSystem"`
ID string `json:"Id"`
}
// Jellyfin represents a running Jellyfin instance.
type Jellyfin struct {
Server string
client string
version string
device string
deviceID string
useragent string
auth string
header map[string]string
ServerInfo serverInfo
Username string
password string
Authenticated bool
AccessToken string
userID string
httpClient *http.Client
loginParams map[string]string
userCache []map[string]interface{}
CacheExpiry time.Time
cacheLength int
noFail bool
timeoutHandler common.TimeoutHandler
}
// NewJellyfin returns a new Jellyfin object.
func NewJellyfin(server, client, version, device, deviceID string, timeoutHandler common.TimeoutHandler, cacheTimeout int) (*Jellyfin, error) {
jf := &Jellyfin{}
jf.Server = server
jf.client = client
jf.version = version
jf.device = device
jf.deviceID = deviceID
jf.useragent = fmt.Sprintf("%s/%s", client, version)
jf.timeoutHandler = timeoutHandler
jf.auth = fmt.Sprintf("MediaBrowser Client=%s, Device=%s, DeviceId=%s, Version=%s", client, device, deviceID, version)
jf.header = map[string]string{
"Accept": "application/json",
"Content-type": "application/json; charset=UTF-8",
"X-Application": jf.useragent,
"Accept-Charset": "UTF-8,*",
"Accept-Encoding": "gzip",
"User-Agent": jf.useragent,
"X-Emby-Authorization": jf.auth,
}
jf.httpClient = &http.Client{
Timeout: 10 * time.Second,
}
infoURL := fmt.Sprintf("%s/System/Info/Public", server)
req, _ := http.NewRequest("GET", infoURL, nil)
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
if err == nil {
data, _ := ioutil.ReadAll(resp.Body)
json.Unmarshal(data, &jf.ServerInfo)
}
jf.cacheLength = cacheTimeout
jf.CacheExpiry = time.Now()
return jf, nil
}
// Authenticate attempts to authenticate using a username & password
func (jf *Jellyfin) Authenticate(username, password string) (map[string]interface{}, int, error) {
jf.Username = username
jf.password = password
jf.loginParams = map[string]string{
"Username": username,
"Pw": password,
"Password": password,
}
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
err := encoder.Encode(jf.loginParams)
if err != nil {
return nil, 0, err
}
// loginParams, _ := json.Marshal(jf.loginParams)
url := fmt.Sprintf("%s/Users/authenticatebyname", jf.Server)
req, err := http.NewRequest("POST", url, buffer)
defer jf.timeoutHandler()
if err != nil {
return nil, 0, err
}
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, resp.StatusCode, err
}
defer resp.Body.Close()
var data io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
data, _ = gzip.NewReader(resp.Body)
default:
data = resp.Body
}
var respData map[string]interface{}
json.NewDecoder(data).Decode(&respData)
jf.AccessToken = respData["AccessToken"].(string)
user := respData["User"].(map[string]interface{})
jf.userID = respData["User"].(map[string]interface{})["Id"].(string)
jf.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\", Token=\"%s\"", jf.client, jf.device, jf.deviceID, jf.version, jf.AccessToken)
jf.header["X-Emby-Authorization"] = jf.auth
jf.Authenticated = true
return user, resp.StatusCode, nil
}
func (jf *Jellyfin) get(url string, params map[string]string) (string, int, error) {
var req *http.Request
if params != nil {
jsonParams, _ := json.Marshal(params)
req, _ = http.NewRequest("GET", url, bytes.NewBuffer(jsonParams))
} else {
req, _ = http.NewRequest("GET", url, nil)
}
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && jf.Authenticated {
jf.Authenticated = false
_, _, authErr := jf.Authenticate(jf.Username, jf.password)
if authErr == nil {
v1, v2, v3 := jf.get(url, params)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
defer resp.Body.Close()
var data io.Reader
encoding := resp.Header.Get("Content-Encoding")
switch encoding {
case "gzip":
data, _ = gzip.NewReader(resp.Body)
default:
data = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, data)
//var respData map[string]interface{}
//json.NewDecoder(data).Decode(&respData)
return buf.String(), resp.StatusCode, nil
}
func (jf *Jellyfin) post(url string, data map[string]interface{}, response bool) (string, int, error) {
params, _ := json.Marshal(data)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(params))
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && jf.Authenticated {
jf.Authenticated = false
_, _, authErr := jf.Authenticate(jf.Username, jf.password)
if authErr == nil {
v1, v2, v3 := jf.post(url, data, response)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
if response {
defer resp.Body.Close()
var outData io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
outData, _ = gzip.NewReader(resp.Body)
default:
outData = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, outData)
return buf.String(), resp.StatusCode, nil
}
return "", resp.StatusCode, nil
}
// DeleteUser deletes the user corresponding to the provided ID.
func (jf *Jellyfin) DeleteUser(id string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", jf.Server, id)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
return resp.StatusCode, err
}
// GetUsers returns all (visible) users on the Jellyfin instance.
func (jf *Jellyfin) GetUsers(public bool) ([]map[string]interface{}, int, error) {
var result []map[string]interface{}
var data string
var status int
var err error
if time.Now().After(jf.CacheExpiry) {
if public {
url := fmt.Sprintf("%s/users/public", jf.Server)
data, status, err = jf.get(url, nil)
} else {
url := fmt.Sprintf("%s/users", jf.Server)
data, status, err = jf.get(url, jf.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
jf.userCache = result
jf.CacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
return result, status, nil
}
return jf.userCache, 200, nil
}
// UserByName returns the user corresponding to the provided username.
func (jf *Jellyfin) UserByName(username string, public bool) (map[string]interface{}, int, error) {
var match map[string]interface{}
find := func() (map[string]interface{}, int, error) {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Name"].(string) == username {
return user, status, err
}
}
return nil, status, err
}
match, status, err := find()
if match == nil {
jf.CacheExpiry = time.Now()
match, status, err = find()
}
return match, status, err
}
// UserByID returns the user corresponding to the provided ID.
func (jf *Jellyfin) UserByID(userID string, public bool) (map[string]interface{}, int, error) {
if jf.CacheExpiry.After(time.Now()) {
for _, user := range jf.userCache {
if user["Id"].(string) == userID {
return user, 200, nil
}
}
}
if public {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Id"].(string) == userID {
return user, status, nil
}
}
return nil, status, err
}
var result map[string]interface{}
var data string
var status int
var err error
url := fmt.Sprintf("%s/users/%s", jf.Server, userID)
data, status, err = jf.get(url, jf.loginParams)
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
return result, status, nil
}
// NewUser creates a new user with the provided username and password.
func (jf *Jellyfin) NewUser(username, password string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/Users/New", jf.Server)
stringData := map[string]string{
"Name": username,
"Password": password,
}
data := make(map[string]interface{})
for key, value := range stringData {
data[key] = value
}
response, status, err := jf.post(url, data, true)
var recv map[string]interface{}
json.Unmarshal([]byte(response), &recv)
if err != nil || !(status == 200 || status == 204) {
return nil, status, err
}
return recv, status, nil
}
// SetPolicy sets the access policy for the user corresponding to the provided ID.
func (jf *Jellyfin) SetPolicy(userID string, policy map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Policy", jf.Server, userID)
_, status, err := jf.post(url, policy, false)
if err != nil || status != 200 {
return status, err
}
return status, nil
}
// SetConfiguration sets the configuration (part of homescreen layout) for the user corresponding to the provided ID.
func (jf *Jellyfin) SetConfiguration(userID string, configuration map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Configuration", jf.Server, userID)
_, status, err := jf.post(url, configuration, false)
return status, err
}
// GetDisplayPreferences gets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (jf *Jellyfin) GetDisplayPreferences(userID string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
data, status, err := jf.get(url, nil)
if err != nil || !(status == 204 || status == 200) {
return nil, status, err
}
var displayprefs map[string]interface{}
err = json.Unmarshal([]byte(data), &displayprefs)
if err != nil {
return nil, status, err
}
return displayprefs, status, nil
}
// SetDisplayPreferences sets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (jf *Jellyfin) SetDisplayPreferences(userID string, displayprefs map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
_, status, err := jf.post(url, displayprefs, false)
if err != nil || !(status == 204 || status == 200) {
return status, err
}
return status, nil
}

130
lang/admin/en-us.json Normal file
View File

@@ -0,0 +1,130 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"invites": "Invites",
"accounts": "Accounts",
"settings": "Settings",
"theme": "Theme",
"inviteDays": "Days",
"inviteHours": "Hours",
"inviteMinutes": "Minutes",
"inviteNumberOfUses": "Number of uses",
"warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to",
"login": "Login",
"logout": "Logout",
"create": "Create",
"apply": "Apply",
"delete": "Delete",
"submit": "Submit",
"name": "Name",
"date": "Date",
"username": "Username",
"password": "Password",
"emailAddress": "Email Address",
"lastActiveTime": "Last Active",
"from": "From",
"user": "User",
"aboutProgram": "About",
"version": "Version",
"commitNoun": "Commit",
"newUser": "New User",
"profile": "Profile",
"success": "Success",
"error": "Error",
"unknown": "Unknown",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"applyHomescreenLayout": "Apply homescreen layout",
"sendDeleteNotificationEmail": "Send notification email",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestartRequired": "Restart needed",
"settingsRestartRequiredDescription": "A restart is necessary to apply some settings you changed. Restart now or later?",
"settingsApplyRestartLater": "Apply, restart later",
"settingsApplyRestartNow": "Apply & restart",
"settingsApplied": "Settings applied.",
"settingsRefreshPage": "Refresh the page in a few seconds",
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
"settingsSave": "Save",
"ombiUserDefaults": "Ombi user defaults",
"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",
"userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
"userProfilesIsDefault": "Default",
"userProfilesLibraries": "Libraries",
"addProfile": "Add Profile",
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
"addProfileNameOf": "Profile Name",
"addProfileStoreHomescreenLayout": "Store homescreen layout",
"inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile",
"copy": "Copy",
"inviteDateCreated": "Created",
"inviteRemainingUses": "Remaining uses",
"inviteNoInvites": "None",
"inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:",
"notifyInviteExpiry": "On expiry",
"notifyUserCreation": "On user creation"
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
"saveSettings": "Settings were saved",
"setOmbiDefaults": "Stored ombi defaults.",
"errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.",
"errorLoginBlank": "The username and/or password were left blank.",
"errorUnknown": "Unknown error.",
"errorBlankFields": "Fields were left blank",
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.",
"errorSaveSettings": "Couldn't save settings.",
"errorLoadSettings": "Failed to load settings.",
"errorSetOmbiDefaults": "Failed to store ombi defaults.",
"errorLoadOmbiUsers": "Failed to load ombi users.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
"errorFailureCheckLogs": "Failed (check console/logs)",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)"
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Modify Settings for {n} user",
"plural": "Modify Settings for {n} users"
},
"deleteNUsers": {
"singular": "Delete {n} user",
"plural": "Delete {n} users"
},
"addUser": {
"singular": "Add user",
"plural": "Add users"
},
"deleteUser": {
"singular": "Delete User",
"plural": "Delete Users"
},
"deletedUser": {
"singular": "Deleted {n} user.",
"plural": "Deleted {n} users."
},
"appliedSettings": {
"singular": "Applied settings to {n} user.",
"plural": "Applied settings to {n} users."
}
}
}

130
lang/admin/fr-fr.json Normal file
View File

@@ -0,0 +1,130 @@
{
"meta": {
"name": "Francais (FR)",
"author": "https://github.com/Killianbe"
},
"strings": {
"invites": "Invite",
"accounts": "Comptes",
"settings": "Reglages",
"theme": "Thème",
"inviteDays": "Jours",
"inviteHours": "Heures",
"inviteMinutes": "Minutes",
"inviteNumberOfUses": "Nombre d'utilisateur",
"warning": "Attention",
"inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement",
"inviteSendToEmail": "Envoyer à",
"login": "S'identifier",
"logout": "Se déconecter",
"create": "Créer",
"apply": "Appliquer",
"delete": "Effacer",
"submit": "Soumettre",
"name": "Nom",
"date": "Date",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"emailAddress": "Addresse Email",
"lastActiveTime": "Dernière activité",
"from": "De",
"user": "Utilisateur",
"aboutProgram": "A propos",
"version": "Version",
"commitNoun": "Commettre",
"newUser": "Nouvel utilisateur",
"profile": "Profil",
"success": "Succès",
"error": "Erreur",
"unknown": "Inconnu",
"modifySettings": "Modifier les paramètres",
"modifySettingsDescription": "Appliquez les paramètres à partir d'un profil existant ou obtenez-les directement auprès d'un utilisateur.",
"applyHomescreenLayout": "Appliquer la disposition de l'écran d'accueil",
"sendDeleteNotificationEmail": "Envoyer un e-mail de notification ",
"sendDeleteNotifiationExample": "Votre compte a été supprimé. ",
"settingsRestartRequired": "Redémarrage nécessaire ",
"settingsRestartRequiredDescription": "Un redémarrage est nécessaire pour appliquer certains paramètres que vous avez modifiés. Redémarrer maintenant ou plus tard?",
"settingsApplyRestartLater": "Appliquer, redémarrer plus tard ",
"settingsApplyRestartNow": "Appliquer et redémarrer ",
"settingsApplied": "Paramètres appliqués.",
"settingsRefreshPage": "Actualisez la page dans quelques secondes ",
"settingsRequiredOrRestartMessage": "Remarque: {n} indique un champ obligatoire, {n} indique que les modifications nécessitent un redémarrage. ",
"settingsSave": "Sauver",
"ombiUserDefaults": "Paramètres par défaut de l'utilisateur Ombi",
"ombiUserDefaultsDescription": "Créez un utilisateur Ombi et configurez-le, puis sélectionnez-le ci-dessous. Ses paramètres / autorisations seront stockés et appliqués aux nouveaux utilisateurs Ombi créés par jfa-go ",
"userProfiles": "Profils d'utilisateurs",
"userProfilesDescription": "Les profils sont appliqués aux utilisateurs lorsqu'ils créent un compte. Un profil inclut les droits d'accès à la bibliothèque et la disposition de l'écran d'accueil. ",
"userProfilesIsDefault": "Défaut",
"userProfilesLibraries": "Bibliothèques",
"addProfile": "Ajouter un profil",
"addProfileDescription": "Créez un utilisateur Jellyfin et configurez-le, puis sélectionnez-le ci-dessous. Lorsque ce profil est appliqué à une invitation, de nouveaux utilisateurs seront créés avec les paramètres. ",
"addProfileNameOf": "Nom de profil",
"addProfileStoreHomescreenLayout": "Enregistrer la disposition de l'écran d'accueil",
"inviteNoUsersCreated": "Aucun pour l'instant!",
"inviteUsersCreated": "Utilisateurs créer",
"inviteNoProfile": "Aucun profil",
"copy": "Copier",
"inviteDateCreated": "Créer",
"inviteRemainingUses": "Utilisations restantes",
"inviteNoInvites": "Aucune",
"inviteExpiresInTime": "Expires dans {n}",
"notifyEvent": "Notifier sur:",
"notifyInviteExpiry": "À l'expiration",
"notifyUserCreation": "à la création de l'utilisateur"
},
"notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
"userCreated": "L'utilisateur {n} a été créé.",
"createProfile": "Profil créé {n}.",
"saveSettings": "Les paramètres ont été enregistrés",
"setOmbiDefaults": "Valeurs par défaut de Ombi.",
"errorConnection": "Impossible de se connecter à jfa-go.",
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
"errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.",
"errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.",
"errorSettingsFailed": "L'application a échoué.",
"errorLoginBlank": "Le nom d'utilisateur et / ou le mot de passe sont vides",
"errorUnknown": "Erreur inconnue.",
"errorBlankFields": "Les champs sont vides",
"errorDeleteProfile": "Échec de la suppression du profil {n}",
"errorLoadProfiles": "Échec du chargement des profils.",
"errorCreateProfile": "Échec de la création du profil {n}",
"errorSetDefaultProfile": "Échec de la définition du profil par défaut",
"errorLoadUsers": "Échec du chargement des utilisateurs.",
"errorSaveSettings": "Impossible d'enregistrer les paramètres.",
"errorLoadSettings": "Échec du chargement des paramètres.",
"errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.",
"errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.",
"errorChangedEmailAddress": "Impossible de modifier l'adresse e-mail de {n}.",
"errorFailureCheckLogs": "Échec (vérifier la console / les journaux)",
"errorPartialFailureCheckLogs": "Panne partielle (vérifier la console / les journaux)"
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Modifier les paramètres pour {n} utilisateur",
"plural": "Modifier les paramètres pour {n} utilisateurs"
},
"deleteNUsers": {
"singular": "Supprimer {n} utilisateur",
"plural": "Supprimer {n} utilisateurs"
},
"addUser": {
"singular": "Ajouter un utilisateur",
"plural": "Ajouter des utilisateurs"
},
"deleteUser": {
"singular": "Supprimer l'utilisateur",
"plural": "Supprimer les utilisateurs"
},
"deletedUser": {
"singular": "Supprimer {n} utilisateur.",
"plural": "Supprimer {n} utilisateurs."
},
"appliedSettings": {
"singular": "Appliquer le paramètre {n} utilisteur.",
"plural": "Appliquer les paramètres {n} utilisteurs."
}
}
}

41
lang/email/en-us.json Normal file
View File

@@ -0,0 +1,41 @@
{
"meta": {
"name": "English (US)"
},
"userCreated": {
"title": "Notice: User created",
"aUserWasCreated": "A user was created using code {n}.",
"name": "Name",
"emailAddress": "Address",
"time": "Time",
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
},
"inviteExpiry": {
"title": "Notice: Invite expired",
"inviteExpired": "Invite expired.",
"expiredAt": "Code {n} expired at {n}.",
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
},
"passwordReset": {
"title": "Password reset requested - Jellyfin",
"helloUser": "Hi {n},",
"someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.",
"ifItWasYou": "If this was you, enter the pin below into the prompt.",
"codeExpiry": "The code will expire on {n}, at {n} UTC, which is in {n}.",
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
"pin": "PIN"
},
"userDeleted": {
"title": "Your account was deleted - Jellyfin",
"yourAccountWasDeleted": "Your Jellyfin account was deleted.",
"reason": "Reason"
},
"inviteEmail": {
"title": "Invite - Jellyfin",
"hello": "Hi",
"youHaveBeenInvited": "You've been invited to Jellyfin.",
"toJoin": "To join, follow the below link.",
"inviteExpiry": "This invite will expire on {n}, at {n}, which is in {n}, so act quick.",
"linkButton": "Setup your account"
}
}

42
lang/email/fr-fr.json Normal file
View File

@@ -0,0 +1,42 @@
{
"meta": {
"name": "Francais (FR)",
"author": "https://github.com/Cornichon420"
},
"userCreated": {
"title": "Notification : Utilisateur créé",
"aUserWasCreated": "Un utilisateur a été créé avec ce code {n}",
"name": "Nom",
"emailAddress": "Adresse",
"time": "Date",
"notificationNotice": ""
},
"inviteExpiry": {
"title": "Notification : Invitation expirée",
"inviteExpired": "Invitation expirée.",
"expiredAt": "Le code {n} a expiré à {n}.",
"notificationNotice": ""
},
"passwordReset": {
"title": "Réinitialisation de mot du passe demandée - Jellyfin",
"helloUser": "Salut {n},",
"someoneHasRequestedReset": "Quelqu'un vient de demander une réinitialisation du mot de passe via Jellyfin.",
"ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.",
"codeExpiry": "Ce code expirera le {n}, à {n} UTC, soit dans {n}.",
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
"pin": "PIN"
},
"userDeleted": {
"title": "Ton compte a été désactivé - Jellyfin",
"yourAccountWasDeleted": "Ton compte Jellyfin a été supprimé.",
"reason": "Motif"
},
"inviteEmail": {
"title": "Invitation - Jellyfin",
"hello": "Salut",
"youHaveBeenInvited": "Tu as été invité à rejoindre Jellyfin.",
"toJoin": "Pour continuer, suis le lien en dessous.",
"inviteExpiry": "L'invitation expirera le {n}, à {n}, soit dans {n}, alors fais vite !",
"linkButton": "Lien"
}
}

View File

@@ -18,7 +18,7 @@
"validationStrings": {
"length": {
"singular": "Must have at least {n} character",
"plural": "Must have a least {n} characters"
"plural": "Must have at least {n} characters"
},
"uppercase": {
"singular": "Must have at least {n} uppercase character",

41
lang/form/nl-nl.json Normal file
View File

@@ -0,0 +1,41 @@
{
"meta": {
"name": "Nederlands (NL)"
},
"strings": {
"pageTitle": "Maak Jellyfin account aan",
"createAccountHeader": "Account aanmaken",
"accountDetails": "Details",
"emailAddress": "Email",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"reEnterPassword": "Bevestig wachtwoord",
"reEnterPasswordInvalid": "Wachtwoorden komen niet overeen.",
"createAccountButton": "Maak account aan",
"passwordRequirementsHeader": "Wachtwoordvereisten",
"successHeader": "Succes!",
"successContinueButton": "Doorgaan",
"validationStrings": {
"length": {
"singular": "Moet ten minste {n} teken bevatten",
"plural": "Moet ten minste {n} tekens bevatten"
},
"uppercase": {
"singular": "Moet ten minste {n} hoofdletter bevatten",
"plural": "Moet ten minste {n} hoofdletters bevatten"
},
"lowercase": {
"singular": "Moet ten minste {n} kleine letter bevatten",
"plural": "Moet ten minste {n} kleine letters bevatten"
},
"number": {
"singular": "Moet ten minste {n} cijfer bevatten",
"plural": "Moet ten minste {n} cijfers bevatten"
},
"special": {
"singular": "Moet ten minste {n} bijzonder teken bevatten",
"plural": "Moet ten minste {n} bijzondere tekens bevatten"
}
}
}
}

View File

@@ -20,26 +20,25 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>User Created</h3>
<p>A user was created using code {{ .code }}.</p>
<p>{{ .aUserWasCreated }}</p>
</mj-text>
<mj-table mj-class="text" container-background-color="#242424">
<tr style="text-align: left;">
<th>Name</th>
<th>Address</th>
<th>Time</th>
<th>{{ .name }}</th>
<th>{{ .address }}</th>
<th>{{ .time }}</th>
</tr>
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
<th>{{ .username }}</th>
<th>{{ .address }}</th>
<th>{{ .time }}</th>
<th>{{ .nameVal }}</th>
<th>{{ .addressVal }}</th>
<th>{{ .timeVal }}</th>
</mj-table>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
Notification emails can be toggled on the admin dashboard.
{{ .notificationNotice }}
</mj-text>
</mj-column>
</mj-section>

View File

@@ -1,7 +1,7 @@
A user was created using code {{ .code }}.
{{ .aUserWasCreated }}
Name: {{ .username }}
Address: {{ .address }}
Time: {{ .time }}
{{ .name }}: {{ .nameVal }}
{{ .address }}: {{ .addressVal }}
{{ .time }}: {{ .timeVal }}
Note: Notification emails can be toggled on the admin dashboard.
{{ .notificationNotice }}

View File

@@ -20,8 +20,8 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Your account was deleted.</h3>
<p>Reason: <i>{{ .reason }}</i></p>
<h3>{{ .yourAccountWasDeleted }}</h3>
<p>{{ .reason }}: <i>{{ .reasonVal }}</i></p>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -1,4 +1,4 @@
Your Jellyfin account was deleted.
Reason: {{ .reason }}
{{ .yourAccountWasDeleted }}
{{ .reason }}: {{ .reasonVal }}
{{ .message }}

View File

@@ -20,13 +20,13 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi {{ .username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ .expiry_date }}, at {{ .expiry_time }} UTC, which is in {{ .expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
<p>{{ .helloUser }}</p>
<p>{{ .someoneHasRequestedReset }}</p>
<p>{{ .ifItWasYou }}</p>
<p>{{ .codeExpiry }}</p>
<p>{{ .ifItWasNotYou }}</p>
</mj-text>
<mj-button mj-class="blue bold">{{ .pin }}</mj-button>
<mj-button mj-class="blue bold">{{ .pinVal }}</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">

View File

@@ -1,10 +1,10 @@
Hi {{ .username }},
{{ .helloUser }}
Someone has recently requests a password reset on Jellyfin.
If this was you, enter the below pin into the prompt.
This code will expire on {{ .expiry_date }}, at {{ .expiry_time }} UTC, which is in {{ .expires_in }}.
If this wasn't you, please ignore this email.
{{ .someoneHasRequestedReset }}
{{ .ifItWasYou }}
{{ .codeExpiry }}
{{ .ifItWasNotYou }}
PIN: {{ .pin }}
{{ .pin }}: {{ .pinVal }}
{{ .message }}

View File

@@ -20,15 +20,15 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Invite Expired.</h3>
<p>Code {{ .code }} expired at {{ .expiry }}.</p>
<h3>{{ .inviteExpired }}</h3>
<p>{{ .expiredAt }}</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">
Notification emails can be toggled on the admin dashboard.
{{ .notificationNotice }}
</mj-text>
</mj-column>
</mj-section>

View File

@@ -1,5 +1,5 @@
Invite expired.
{{ .inviteExpired }}
Code {{ .code }} expired at {{ .expiry }}.
{{ .expiredAt }}
Note: Notification emails can be toggled on the admin dashboard.
{{ .notificationNotice }}

View File

@@ -1,8 +1,13 @@
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("-o", "--output", help="output directory for .html and .txt files")
args = parser.parse_args()
def runcmd(cmd):
if os.name == "nt":
@@ -22,7 +27,8 @@ for mjml in [f for f in local_path.iterdir() if f.is_file() and "mjml" in f.suff
html = [f for f in local_path.iterdir() if f.is_file() and "html" in f.suffix]
output = local_path.parent / "data"
output = Path(args.output) # local_path.parent / "build" / "data"
output.mkdir(parents=True, exist_ok=True)
for f in html:
shutil.copy(str(f), str(output / f.name))

View File

@@ -20,12 +20,12 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi,</p>
<h3>You've been invited to Jellyfin.</h3>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ .expiry_date }}, at {{ .expiry_time }}, which is in {{ .expires_in }}, so act quick.</p>
<p>{{ .hello }},</p>
<h3>{{ .youHaveBeenInvited }}</h3>
<p>{{ .toJoin }}</p>
<p>{{ .inviteExpiry }}</p>
</mj-text>
<mj-button mj-class="blue bold" href="{{ .invite_link }}">Setup your account</mj-button>
<mj-button mj-class="blue bold" href="{{ .invite_link }}">{{ .linkButton }}</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">

View File

@@ -1,7 +1,7 @@
Hi,
You've been invited to Jellyfin.
To join, follow the below link.
This invite will expire on {{ .expiry_date }}, at {{ .expiry_time }}, which is in {{ .expires_in }}, so act quick.
{{ .hello }},
{{ .youHaveBeenInvited }}
{{ .toJoin }}
{{ .inviteExpiry }}
{{ .invite_link }}

253
main.go
View File

@@ -17,6 +17,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -25,7 +26,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/jfapi"
"github.com/hrfee/jfa-go/mediabrowser"
"github.com/hrfee/jfa-go/ombi"
"github.com/lithammer/shortuuid/v3"
"github.com/logrusorgru/aurora/v3"
@@ -34,28 +35,36 @@ import (
"gopkg.in/ini.v1"
)
// Username is JWT!
var serverTypes = map[string]string{
"jellyfin": "Jellyfin",
"emby": "Emby (experimental)",
}
var serverType = mediabrowser.JellyfinServer
var substituteStrings = ""
// User is used for auth purposes.
type User struct {
UserID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}
// contains everything the application needs, essentially. Wouldn't do this in the future.
type appContext struct {
// defaults *Config
config *ini.File
config_path string
configBase_path string
configBase map[string]interface{}
data_path string
local_path string
cssFile string
bsVersion int
jellyfinLogin bool
users []User
invalidTokens []string
jf *jfapi.Jellyfin
authJf *jfapi.Jellyfin
config *ini.File
configPath string
configBasePath string
configBase settings
dataPath string
localPath string
cssClass string
jellyfinLogin bool
users []User
invalidTokens []string
// Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi
datePattern string
timePattern string
@@ -67,21 +76,15 @@ type appContext struct {
port int
version string
quit chan os.Signal
lang Languages
}
type Languages struct {
langFiles []os.FileInfo // Language filenames
langOptions []string // Language names
chosenIndex int
URLBase string
}
func (app *appContext) loadHTML(router *gin.Engine) {
customPath := app.config.Section("files").Key("html_templates").MustString("")
templatePath := filepath.Join(app.local_path, "templates")
templatePath := filepath.Join(app.localPath, "html")
htmlFiles, err := ioutil.ReadDir(templatePath)
if err != nil {
app.err.Fatalf("Couldn't access template directory: \"%s\"", filepath.Join(app.local_path, "templates"))
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
return
}
loadFiles := make([]string, len(htmlFiles))
@@ -97,7 +100,7 @@ func (app *appContext) loadHTML(router *gin.Engine) {
router.LoadHTMLFiles(loadFiles...)
}
func GenerateSecret(length int) (string, error) {
func generateSecret(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
@@ -173,7 +176,7 @@ func test(app *appContext) {
fmt.Scanln(&username)
user, status, err := app.jf.UserByName(username, false)
fmt.Printf("UserByName (%s): code %d err %s", username, status, err)
out, err := json.MarshalIndent(user, "", " ")
out, _ := json.MarshalIndent(user, "", " ")
fmt.Print(string(out))
}
@@ -187,17 +190,17 @@ func start(asDaemon, firstCall bool) {
local_path is the internal 'data' directory.
*/
userConfigDir, _ := os.UserConfigDir()
app.data_path = filepath.Join(userConfigDir, "jfa-go")
app.config_path = filepath.Join(app.data_path, "config.ini")
app.dataPath = filepath.Join(userConfigDir, "jfa-go")
app.configPath = filepath.Join(app.dataPath, "config.ini")
executable, _ := os.Executable()
app.local_path = filepath.Join(filepath.Dir(executable), "data")
app.localPath = filepath.Join(filepath.Dir(executable), "data")
app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
if firstCall {
DATA = flag.String("data", app.data_path, "alternate path to data directory.")
CONFIG = flag.String("config", app.config_path, "alternate path to config file.")
DATA = flag.String("data", app.dataPath, "alternate path to data directory.")
CONFIG = flag.String("config", app.configPath, "alternate path to config file.")
HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
DEBUG = flag.Bool("debug", false, "Enables debug logging and exposes pprof.")
@@ -219,35 +222,35 @@ func start(asDaemon, firstCall bool) {
*DEBUG = true
}
// attempt to apply command line flags correctly
if app.config_path == *CONFIG && app.data_path != *DATA {
app.data_path = *DATA
app.config_path = filepath.Join(app.data_path, "config.ini")
} else if app.config_path != *CONFIG && app.data_path == *DATA {
app.config_path = *CONFIG
if app.configPath == *CONFIG && app.dataPath != *DATA {
app.dataPath = *DATA
app.configPath = filepath.Join(app.dataPath, "config.ini")
} else if app.configPath != *CONFIG && app.dataPath == *DATA {
app.configPath = *CONFIG
} else {
app.config_path = *CONFIG
app.data_path = *DATA
app.configPath = *CONFIG
app.dataPath = *DATA
}
// env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
app.config_path = v
app.configPath = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
app.data_path = v
app.dataPath = v
}
os.Setenv("JFA_CONFIGPATH", app.config_path)
os.Setenv("JFA_DATAPATH", app.data_path)
os.Setenv("JFA_CONFIGPATH", app.configPath)
os.Setenv("JFA_DATAPATH", app.dataPath)
var firstRun bool
if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
os.Mkdir(app.data_path, 0700)
if _, err := os.Stat(app.dataPath); os.IsNotExist(err) {
os.Mkdir(app.dataPath, 0700)
}
if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
if _, err := os.Stat(app.configPath); os.IsNotExist(err) {
firstRun = true
dConfigPath := filepath.Join(app.local_path, "config-default.ini")
dConfigPath := filepath.Join(app.localPath, "config-default.ini")
var dConfig *os.File
dConfig, err = os.Open(dConfigPath)
if err != nil {
@@ -255,30 +258,24 @@ func start(asDaemon, firstCall bool) {
}
defer dConfig.Close()
var nConfig *os.File
nConfig, err := os.Create(app.config_path)
nConfig, err := os.Create(app.configPath)
if err != nil {
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.config_path)
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.configPath)
app.err.Fatalf("Error: %s", err)
}
defer nConfig.Close()
_, err = io.Copy(nConfig, dConfig)
if err != nil {
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.configPath)
}
app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
app.info.Printf("Copied default configuration to \"%s\"", app.configPath)
}
var debugMode bool
var address string
if app.loadConfig() != nil {
app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
app.err.Fatalf("Failed to load config file \"%s\"", app.configPath)
}
lang := app.config.Section("ui").Key("language").MustString("en-us")
app.storage.lang.FormPath = filepath.Join(app.local_path, "lang", "form", lang+".json")
if _, err := os.Stat(app.storage.lang.FormPath); os.IsNotExist(err) {
app.storage.lang.FormPath = filepath.Join(app.local_path, "lang", "form", "en-us.json")
}
app.storage.loadLang()
app.version = app.config.Section("jellyfin").Key("version").String()
// read from config...
debugMode = app.config.Section("ui").Key("debug").MustBool(false)
@@ -334,7 +331,12 @@ func start(asDaemon, firstCall bool) {
if !firstRun {
app.host = app.config.Section("ui").Key("host").String()
app.port = app.config.Section("ui").Key("port").MustInt(8056)
if app.config.Section("advanced").Key("tls").MustBool(false) {
app.info.Println("Using TLS/HTTP2")
app.port = app.config.Section("advanced").Key("tls_port").MustInt(8057)
} else {
app.port = app.config.Section("ui").Key("port").MustInt(8056)
}
if *HOST != app.host && *HOST != "" {
app.host = *HOST
@@ -353,18 +355,9 @@ func start(asDaemon, firstCall bool) {
}
}
}
address = fmt.Sprintf("%s:%d", app.host, app.port)
app.debug.Printf("Loaded config file \"%s\"", app.config_path)
if app.config.Section("ui").Key("bs5").MustBool(false) {
app.cssFile = "bs5-jf.css"
app.bsVersion = 5
} else {
app.cssFile = "bs4-jf.css"
app.bsVersion = 4
}
app.debug.Printf("Loaded config file \"%s\"", app.configPath)
app.debug.Println("Loading storage")
@@ -381,7 +374,6 @@ func start(asDaemon, firstCall bool) {
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()
if !(len(app.storage.policy) == 0 && len(app.storage.configuration) == 0 && len(app.storage.displayprefs) == 0) {
app.info.Println("Migrating user template files to new profile format")
app.storage.migrateToProfile()
@@ -411,20 +403,21 @@ func start(asDaemon, firstCall bool) {
}
app.configBase_path = filepath.Join(app.local_path, "config-base.json")
configBase, _ := ioutil.ReadFile(app.configBase_path)
app.configBasePath = filepath.Join(app.localPath, "config-base.json")
configBase, _ := ioutil.ReadFile(app.configBasePath)
json.Unmarshal(configBase, &app.configBase)
themes := map[string]string{
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion),
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
"Custom CSS": "",
"Jellyfin (Dark)": "dark-theme",
"Default (Light)": "light-theme",
}
if app.config.Section("ui").Key("theme").String() == "Bootstrap (Light)" {
app.config.Section("ui").Key("theme").SetValue("Default (Light)")
}
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
app.cssFile = val
app.cssClass = val
}
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := GenerateSecret(16)
secret, err := generateSecret(16)
if err != nil {
app.err.Fatal(err)
}
@@ -443,13 +436,25 @@ func start(asDaemon, firstCall bool) {
server := app.config.Section("jellyfin").Key("server").String()
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
app.jf, _ = jfapi.NewJellyfin(
stringServerType := app.config.Section("jellyfin").Key("type").String()
timeoutHandler := common.NewTimeoutHandler("Jellyfin", server, true)
if stringServerType == "emby" {
serverType = mediabrowser.EmbyServer
timeoutHandler = common.NewTimeoutHandler("Emby", server, true)
app.info.Println("Using Emby server type")
fmt.Println(aurora.Yellow("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available."))
} else {
app.info.Println("Using Jellyfin server type")
}
app.jf, _ = mediabrowser.NewServer(
serverType,
server,
app.config.Section("jellyfin").Key("client").String(),
app.config.Section("jellyfin").Key("version").String(),
app.config.Section("jellyfin").Key("device").String(),
app.config.Section("jellyfin").Key("device_id").String(),
common.NewTimeoutHandler("Jellyfin", server, true),
timeoutHandler,
cacheTimeout,
)
var status int
@@ -458,7 +463,68 @@ func start(asDaemon, firstCall bool) {
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
}
app.info.Printf("Authenticated with %s", server)
app.authJf, _ = jfapi.NewJellyfin(server, "jfa-go", app.version, "auth", "auth", common.NewTimeoutHandler("Jellyfin", server, true), cacheTimeout)
// from 10.7.0, jellyfin may hyphenate user IDs. This checks if the version is equal or higher.
checkVersion := func(version string) int {
numberStrings := strings.Split(version, ".")
n := 0
for _, s := range numberStrings {
num, err := strconv.Atoi(s)
if err == nil {
n += num
}
}
return n
}
if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") {
// Get users to check if server uses hyphenated userIDs
app.jf.GetUsers(false)
noHyphens := true
for id := range app.storage.emails {
if strings.Contains(id, "-") {
noHyphens = false
break
}
}
if noHyphens == app.jf.Hyphens {
var newEmails map[string]interface{}
var status int
var err error
if app.jf.Hyphens {
app.info.Println(aurora.Yellow("Your build of Jellyfin appears to hypenate user IDs. Your emails.json file will be modified to match."))
time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails)
} else {
app.info.Println(aurora.Yellow("Your emails.json file uses hyphens, but the Jellyfin server no longer does. It will be modified."))
time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails)
}
if status != 200 || err != nil {
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
app.err.Fatalf("Couldn't upgrade emails.json")
}
bakFile := app.storage.emails_path + ".bak"
err = storeJSON(bakFile, app.storage.emails)
if err != nil {
app.err.Fatalf("couldn't store emails.json backup: %s", err)
}
app.storage.emails = newEmails
err = app.storage.storeEmails()
if err != nil {
app.err.Fatalf("couldn't store emails.json: %s", err)
}
}
}
app.storage.lang.FormPath = filepath.Join(app.localPath, "lang", "form")
app.storage.lang.AdminPath = filepath.Join(app.localPath, "lang", "admin")
app.storage.lang.EmailPath = filepath.Join(app.localPath, "lang", "email")
err = app.storage.loadLang()
if err != nil {
app.info.Fatalf("Failed to load language files: %+v\n", err)
}
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
app.loadStrftime()
@@ -481,10 +547,10 @@ func start(asDaemon, firstCall bool) {
os.Exit(0)
}
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.Run()
inviteDaemon := newRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.run()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR()
}
} else {
@@ -502,7 +568,8 @@ func start(asDaemon, firstCall bool) {
setGinLogger(router, debugMode)
router.Use(gin.Recovery())
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
router.Use(static.Serve("/lang/", static.LocalFile(filepath.Join(app.localPath, "lang"), false)))
app.loadHTML(router)
router.NoRoute(app.NoRouteHandler)
if debugMode {
@@ -511,10 +578,14 @@ func start(asDaemon, firstCall bool) {
}
if !firstRun {
router.GET("/", app.AdminPage)
router.GET("/accounts", app.AdminPage)
router.GET("/settings", app.AdminPage)
router.GET("/lang/:page", app.GetLanguages)
router.GET("/token/login", app.getTokenLogin)
router.GET("/token/refresh", app.getTokenRefresh)
router.POST("/newUser", app.NewUser)
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
router.GET("/invite/:invCode", app.InviteProxy)
if *SWAGGER {
app.info.Print(aurora.Magenta("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@@ -558,8 +629,16 @@ func start(asDaemon, firstCall bool) {
Handler: router,
}
go func() {
if err := SRV.ListenAndServe(); err != nil {
app.err.Printf("Failure serving: %s", err)
if app.config.Section("advanced").Key("tls").MustBool(false) {
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)
}
} else {
if err := SRV.ListenAndServe(); err != nil {
app.err.Printf("Failure serving: %s", err)
}
}
}()
app.quit = make(chan os.Signal)

175
mediabrowser/emby.go Normal file
View File

@@ -0,0 +1,175 @@
package mediabrowser
// Almost identical to jfapi, with the most notable change being the password workaround.
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func embyDeleteUser(emby *MediaBrowser, userID string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", emby.Server, userID)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range emby.header {
req.Header.Add(name, value)
}
resp, err := emby.httpClient.Do(req)
defer emby.timeoutHandler()
return resp.StatusCode, err
}
func embyGetUsers(emby *MediaBrowser, public bool) ([]map[string]interface{}, int, error) {
var result []map[string]interface{}
var data string
var status int
var err error
if time.Now().After(emby.CacheExpiry) {
if public {
url := fmt.Sprintf("%s/users/public", emby.Server)
data, status, err = emby.get(url, nil)
} else {
url := fmt.Sprintf("%s/users", emby.Server)
data, status, err = emby.get(url, emby.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
emby.userCache = result
emby.CacheExpiry = time.Now().Add(time.Minute * time.Duration(emby.cacheLength))
if id, ok := result[0]["Id"]; ok {
if id.(string)[8] == '-' {
emby.Hyphens = true
}
}
return result, status, nil
}
return emby.userCache, 200, nil
}
func embyUserByName(emby *MediaBrowser, username string, public bool) (map[string]interface{}, int, error) {
var match map[string]interface{}
find := func() (map[string]interface{}, int, error) {
users, status, err := emby.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Name"].(string) == username {
return user, status, err
}
}
return nil, status, err
}
match, status, err := find()
if match == nil {
emby.CacheExpiry = time.Now()
match, status, err = find()
}
return match, status, err
}
func embyUserByID(emby *MediaBrowser, userID string, public bool) (map[string]interface{}, int, error) {
if emby.CacheExpiry.After(time.Now()) {
for _, user := range emby.userCache {
if user["Id"].(string) == userID {
return user, 200, nil
}
}
}
if public {
users, status, err := emby.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Id"].(string) == userID {
return user, status, nil
}
}
return nil, status, err
}
var result map[string]interface{}
var data string
var status int
var err error
url := fmt.Sprintf("%s/users/%s", emby.Server, userID)
data, status, err = emby.get(url, emby.loginParams)
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
return result, status, nil
}
// Since emby doesn't allow one to specify a password on user creation, we:
// Create the account
// Immediately disable it
// Set password
// Reeenable it
func embyNewUser(emby *MediaBrowser, username, password string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/Users/New", emby.Server)
data := map[string]interface{}{
"Name": username,
}
response, status, err := emby.post(url, data, true)
var recv map[string]interface{}
json.Unmarshal([]byte(response), &recv)
if err != nil || !(status == 200 || status == 204) {
return nil, status, err
}
// Step 2: Set password
id := recv["Id"].(string)
url = fmt.Sprintf("%s/Users/%s/Password", emby.Server, id)
data = map[string]interface{}{
"Id": id,
"CurrentPw": "",
"NewPw": password,
}
_, status, err = emby.post(url, data, false)
// Step 3: If setting password errored, try to delete the account
if err != nil || !(status == 200 || status == 204) {
_, err = emby.DeleteUser(id)
}
return recv, status, nil
}
func embySetPolicy(emby *MediaBrowser, userID string, policy map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Policy", emby.Server, userID)
_, status, err := emby.post(url, policy, false)
if err != nil || status != 200 {
return status, err
}
return status, nil
}
func embySetConfiguration(emby *MediaBrowser, userID string, configuration map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Configuration", emby.Server, userID)
_, status, err := emby.post(url, configuration, false)
return status, err
}
func embyGetDisplayPreferences(emby *MediaBrowser, userID string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
data, status, err := emby.get(url, nil)
if err != nil || !(status == 204 || status == 200) {
return nil, status, err
}
var displayprefs map[string]interface{}
err = json.Unmarshal([]byte(data), &displayprefs)
if err != nil {
return nil, status, err
}
return displayprefs, status, nil
}
func embySetDisplayPreferences(emby *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
_, status, err := emby.post(url, displayprefs, false)
if err != nil || !(status == 204 || status == 200) {
return status, err
}
return status, nil
}

7
mediabrowser/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/hrfee/jfa-go/mediabrowser
go 1.15
replace github.com/hrfee/jfa-go/common => ../common
require github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc

160
mediabrowser/jfapi.go Normal file
View File

@@ -0,0 +1,160 @@
package mediabrowser
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func jfDeleteUser(jf *MediaBrowser, userID string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", jf.Server, userID)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
return resp.StatusCode, err
}
func jfGetUsers(jf *MediaBrowser, public bool) ([]map[string]interface{}, int, error) {
var result []map[string]interface{}
var data string
var status int
var err error
if time.Now().After(jf.CacheExpiry) {
if public {
url := fmt.Sprintf("%s/users/public", jf.Server)
data, status, err = jf.get(url, nil)
} else {
url := fmt.Sprintf("%s/users", jf.Server)
data, status, err = jf.get(url, jf.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
jf.userCache = result
jf.CacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
if id, ok := result[0]["Id"]; ok {
if id.(string)[8] == '-' {
jf.Hyphens = true
}
}
return result, status, nil
}
return jf.userCache, 200, nil
}
func jfUserByName(jf *MediaBrowser, username string, public bool) (map[string]interface{}, int, error) {
var match map[string]interface{}
find := func() (map[string]interface{}, int, error) {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Name"].(string) == username {
return user, status, err
}
}
return nil, status, err
}
match, status, err := find()
if match == nil {
jf.CacheExpiry = time.Now()
match, status, err = find()
}
return match, status, err
}
func jfUserByID(jf *MediaBrowser, userID string, public bool) (map[string]interface{}, int, error) {
if jf.CacheExpiry.After(time.Now()) {
for _, user := range jf.userCache {
if user["Id"].(string) == userID {
return user, 200, nil
}
}
}
if public {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return nil, status, err
}
for _, user := range users {
if user["Id"].(string) == userID {
return user, status, nil
}
}
return nil, status, err
}
var result map[string]interface{}
var data string
var status int
var err error
url := fmt.Sprintf("%s/users/%s", jf.Server, userID)
data, status, err = jf.get(url, jf.loginParams)
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
return result, status, nil
}
func jfNewUser(jf *MediaBrowser, username, password string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/Users/New", jf.Server)
stringData := map[string]string{
"Name": username,
"Password": password,
}
data := make(map[string]interface{})
for key, value := range stringData {
data[key] = value
}
response, status, err := jf.post(url, data, true)
var recv map[string]interface{}
json.Unmarshal([]byte(response), &recv)
if err != nil || !(status == 200 || status == 204) {
return nil, status, err
}
return recv, status, nil
}
func jfSetPolicy(jf *MediaBrowser, userID string, policy map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Policy", jf.Server, userID)
_, status, err := jf.post(url, policy, false)
if err != nil || status != 200 {
return status, err
}
return status, nil
}
func jfSetConfiguration(jf *MediaBrowser, userID string, configuration map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Configuration", jf.Server, userID)
_, status, err := jf.post(url, configuration, false)
return status, err
}
func jfGetDisplayPreferences(jf *MediaBrowser, userID string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
data, status, err := jf.get(url, nil)
if err != nil || !(status == 204 || status == 200) {
return nil, status, err
}
var displayprefs map[string]interface{}
err = json.Unmarshal([]byte(data), &displayprefs)
if err != nil {
return nil, status, err
}
return displayprefs, status, nil
}
func jfSetDisplayPreferences(jf *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
_, status, err := jf.post(url, displayprefs, false)
if err != nil || !(status == 204 || status == 200) {
return status, err
}
return status, nil
}

View File

@@ -0,0 +1,288 @@
package mediabrowser
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
)
type serverType bool
var JellyfinServer serverType = false
var EmbyServer serverType = true
type serverInfo struct {
LocalAddress string `json:"LocalAddress"`
Name string `json:"ServerName"`
Version string `json:"Version"`
OS string `json:"OperatingSystem"`
ID string `json:"Id"`
}
// MediaBrowser is an api instance of Jellyfin/Emby.
type MediaBrowser struct {
Server string
client string
version string
device string
deviceID string
useragent string
auth string
header map[string]string
ServerInfo serverInfo
Username string
password string
Authenticated bool
AccessToken string
userID string
httpClient *http.Client
loginParams map[string]string
userCache []map[string]interface{}
CacheExpiry time.Time
cacheLength int
noFail bool
Hyphens bool
serverType serverType
timeoutHandler common.TimeoutHandler
}
// NewServer returns a new Jellyfin object.
func NewServer(st serverType, server, client, version, device, deviceID string, timeoutHandler common.TimeoutHandler, cacheTimeout int) (*MediaBrowser, error) {
mb := &MediaBrowser{}
mb.serverType = st
mb.Server = server
mb.client = client
mb.version = version
mb.device = device
mb.deviceID = deviceID
mb.useragent = fmt.Sprintf("%s/%s", client, version)
mb.timeoutHandler = timeoutHandler
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\"", client, device, deviceID, version)
mb.header = map[string]string{
"Accept": "application/json",
"Content-type": "application/json; charset=UTF-8",
"X-Application": mb.useragent,
"Accept-Charset": "UTF-8,*",
"Accept-Encoding": "gzip",
"User-Agent": mb.useragent,
"X-Emby-Authorization": mb.auth,
}
mb.httpClient = &http.Client{
Timeout: 10 * time.Second,
}
infoURL := fmt.Sprintf("%s/System/Info/Public", server)
req, _ := http.NewRequest("GET", infoURL, nil)
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err == nil {
data, _ := ioutil.ReadAll(resp.Body)
json.Unmarshal(data, &mb.ServerInfo)
}
mb.cacheLength = cacheTimeout
mb.CacheExpiry = time.Now()
return mb, nil
}
func (mb *MediaBrowser) get(url string, params map[string]string) (string, int, error) {
var req *http.Request
if params != nil {
jsonParams, _ := json.Marshal(params)
req, _ = http.NewRequest("GET", url, bytes.NewBuffer(jsonParams))
} else {
req, _ = http.NewRequest("GET", url, nil)
}
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && mb.Authenticated {
mb.Authenticated = false
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
if authErr == nil {
v1, v2, v3 := mb.get(url, params)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
defer resp.Body.Close()
var data io.Reader
encoding := resp.Header.Get("Content-Encoding")
switch encoding {
case "gzip":
data, _ = gzip.NewReader(resp.Body)
default:
data = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, data)
//var respData map[string]interface{}
//json.NewDecoder(data).Decode(&respData)
return buf.String(), resp.StatusCode, nil
}
func (mb *MediaBrowser) post(url string, data map[string]interface{}, response bool) (string, int, error) {
params, _ := json.Marshal(data)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(params))
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && mb.Authenticated {
mb.Authenticated = false
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
if authErr == nil {
v1, v2, v3 := mb.post(url, data, response)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
if response {
defer resp.Body.Close()
var outData io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
outData, _ = gzip.NewReader(resp.Body)
default:
outData = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, outData)
return buf.String(), resp.StatusCode, nil
}
return "", resp.StatusCode, nil
}
// Authenticate attempts to authenticate using a username & password
func (mb *MediaBrowser) Authenticate(username, password string) (map[string]interface{}, int, error) {
mb.Username = username
mb.password = password
mb.loginParams = map[string]string{
"Username": username,
"Pw": password,
"Password": password,
}
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
err := encoder.Encode(mb.loginParams)
if err != nil {
return nil, 0, err
}
// loginParams, _ := json.Marshal(jf.loginParams)
url := fmt.Sprintf("%s/Users/authenticatebyname", mb.Server)
req, err := http.NewRequest("POST", url, buffer)
defer mb.timeoutHandler()
if err != nil {
return nil, 0, err
}
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, resp.StatusCode, err
}
defer resp.Body.Close()
var data io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
data, _ = gzip.NewReader(resp.Body)
default:
data = resp.Body
}
var respData map[string]interface{}
json.NewDecoder(data).Decode(&respData)
mb.AccessToken = respData["AccessToken"].(string)
user := respData["User"].(map[string]interface{})
mb.userID = respData["User"].(map[string]interface{})["Id"].(string)
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\", Token=\"%s\"", mb.client, mb.device, mb.deviceID, mb.version, mb.AccessToken)
mb.header["X-Emby-Authorization"] = mb.auth
mb.Authenticated = true
return user, resp.StatusCode, nil
}
// DeleteUser deletes the user corresponding to the provided ID.
func (mb *MediaBrowser) DeleteUser(userID string) (int, error) {
if mb.serverType == JellyfinServer {
return jfDeleteUser(mb, userID)
}
return embyDeleteUser(mb, userID)
}
// GetUsers returns all (visible) users on the Emby instance.
func (mb *MediaBrowser) GetUsers(public bool) ([]map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfGetUsers(mb, public)
}
return embyGetUsers(mb, public)
}
// UserByName returns the user corresponding to the provided username.
func (mb *MediaBrowser) UserByName(username string, public bool) (map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfUserByName(mb, username, public)
}
return embyUserByName(mb, username, public)
}
// UserByID returns the user corresponding to the provided ID.
func (mb *MediaBrowser) UserByID(userID string, public bool) (map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfUserByID(mb, userID, public)
}
return embyUserByID(mb, userID, public)
}
// NewUser creates a new user with the provided username and password.
func (mb *MediaBrowser) NewUser(username, password string) (map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfNewUser(mb, username, password)
}
return embyNewUser(mb, username, password)
}
// SetPolicy sets the access policy for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetPolicy(userID string, policy map[string]interface{}) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetPolicy(mb, userID, policy)
}
return embySetPolicy(mb, userID, policy)
}
// SetConfiguration sets the configuration (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetConfiguration(userID string, configuration map[string]interface{}) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetConfiguration(mb, userID, configuration)
}
return embySetConfiguration(mb, userID, configuration)
}
// GetDisplayPreferences gets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) GetDisplayPreferences(userID string) (map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfGetDisplayPreferences(mb, userID)
}
return embyGetDisplayPreferences(mb, userID)
}
// SetDisplayPreferences sets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetDisplayPreferences(userID string, displayprefs map[string]interface{}) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetDisplayPreferences(mb, userID, displayprefs)
}
return embySetDisplayPreferences(mb, userID, displayprefs)
}

View File

@@ -127,3 +127,34 @@ type errorListDTO map[string]map[string]string
type configDTO map[string]interface{}
// Below are for sending config
type meta struct {
Name string `json:"name"`
Description string `json:"description"`
}
type setting struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
RequiresRestart bool `json:"requires_restart"`
Type string `json:"type"` // Type (string, number, bool, etc.)
Value interface{} `json:"value"`
Options []string `json:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
}
type section struct {
Meta meta `json:"meta"`
Order []string `json:"order"`
Settings map[string]setting `json:"settings"`
}
type settings struct {
Order []string `json:"order"`
Sections map[string]section `json:"sections"`
}
type langDTO map[string]string

2808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "jellyfin-accounts",
"name": "jfa-go",
"version": "1.0.0",
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
"main": "index.js",
@@ -8,24 +8,21 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
"url": "git+https://github.com/hrfee/jfa-go.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
"url": "https://github.com/hrfee/jfa-go/issues"
},
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@types/jquery": "^3.5.3",
"autoprefixer": "^9.8.5",
"bootstrap": "^5.0.0-alpha3",
"bootstrap4": "npm:bootstrap@^4.5.0",
"clean-css-cli": "^4.3.0",
"a17t": "^0.4.0",
"esbuild": "^0.7.8",
"lodash": "^4.17.19",
"mjml": "^4.6.3",
"postcss-cli": "^7.1.1",
"mjml": "^4.8.0",
"remixicon": "^2.5.0",
"typescript": "^4.0.3"
}
},
"devDependencies": {}
}

View File

@@ -34,7 +34,8 @@ func (app *appContext) StartPWR() {
<-done
}
type Pwr struct {
// PasswordReset represents a passwordreset-xyz.json file generated by Jellyfin.
type PasswordReset struct {
Pin string `json:"Pin"`
Username string `json:"UserName"`
Expiry time.Time `json:"ExpirationDate"`
@@ -48,7 +49,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
return
}
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
var pwr Pwr
var pwr PasswordReset
data, err := ioutil.ReadFile(event.Name)
if err != nil {
return

View File

@@ -4,16 +4,15 @@ import (
"unicode"
)
// Validator allows for validation of passwords.
type Validator struct {
minLength, upper, lower, number, special int
criteria ValidatorConf
specialChars []rune
}
type ValidatorConf map[string]int
func (vd *Validator) init(criteria ValidatorConf) {
vd.specialChars = []rune{'[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')', '<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']'}
vd.criteria = criteria
}
@@ -39,12 +38,8 @@ func (vd *Validator) validate(password string) map[string]bool {
count["lowercase"] += 1
} else if unicode.IsNumber(c) {
count["number"] += 1
} else {
for _, s := range vd.specialChars {
if c == s {
count["special"] += 1
}
}
} else if unicode.ToUpper(c) == unicode.ToLower(c) {
count["special"] += 1
}
}
results := map[string]bool{}

View File

@@ -1,10 +0,0 @@
## SCSS
* `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it.
**Note**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`.
* Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages.
* If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS.
* If you are creating custom css, run `poetry run task get-npm-deps` to only install the necessary dependencies. Follow along with the commands `scss/compile.py` runs to build your css and then set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.

View File

@@ -1,126 +0,0 @@
.pageContainer {
margin: 5% 30% 5% 30%;
}
@media (max-width: 1900px) {
.pageContainer {
margin: 5% 20% 5% 20%;
}
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%;
}
.tabGroup {
/*margin: 20%;*/
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
/*margin: 20%;*/
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
/*margin: 20%;*/
margin-top: 5%;
color: grey;
}
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.settingIcon {
margin-left: 0.2rem;
}
body.modal-open {
overflow: hidden;
}
@mixin white-text {
&, &:visited, &:hover, &:active {
font-style: inherit;
color: inherit;
font-size: inherit;
text-decoration: none;
font-variant: inherit;
font-weight: inherit;
line-height: inherit;
font-family: inherit;
}
}
%white-text {
@include white-text;
}
%link-unstyled {
@include white-text;
background-color: transparent;
margin-right: 0.5rem;
}
.text-button {
@extend %link-unstyled;
}
.text-button:hover {
@extend %link-unstyled;
}
.nl {
@extend %link-unstyled;
}
.nl:hover {
@extend %white-text;
}
.unfocused {
display: none;
}
.text-monospace {
font-family: monospace;
}

View File

@@ -1,145 +0,0 @@
$jf-blue: rgb(0, 164, 220);
$jf-blue-hover: rgba(0, 164, 220, 0.2);
$jf-blue-focus: rgb(12, 176, 232);
$jf-blue-light: #4bb3dd;
$jf-red: rgb(204, 0, 0);
$jf-red-light: #e12026;
$jf-yellower: #ffc107;
$jf-yellow: #e1b222;
$jf-orange: #ff870f;
$jf-green: #6fbd45;
$jf-green-dark: #008040;
$jf-black: #101010; // 16 16 16
$jf-gray-90: #202020; // 32 32 32
$jf-gray-80: #242424; // jf-card 36 36 36
$jf-gray-70: #292929; // jf-input 41 41 41
$jf-gray-60: #303030; // jf-button 48 48 48
$jf-gray-50: #383838; // jf-button-focus 56 56 56
$jf-text-bold: rgba(255, 255, 255, 0.87);
$jf-text-primary: rgba(255, 255, 255, 0.8);
$jf-text-secondary: rgb(153, 153, 153);
$primary: $jf-blue;
$secondary: $jf-gray-50;
$success: $jf-green-dark;
$danger: $jf-red-light;
$light: $jf-text-primary;
$dark: $jf-gray-90;
$info: $jf-yellow;
$warning: $jf-yellower;
$enable-gradients: false;
$enable-shadows: false;
$enable-rounded: false;
$body-bg: $jf-black;
$body-color: $jf-text-primary;
$border-color: $jf-gray-60;
$component-active-color: $jf-text-bold;
$component-active-bg: $jf-blue-focus;
$text-muted: $jf-text-secondary;
$link-color: $jf-blue-focus;
$btn-link-disabled-color: $jf-text-secondary;
$input-bg: $jf-gray-90;
$input-color: $jf-text-primary;
$input-focus-bg: $jf-gray-60;
$input-focus-border-color: $jf-blue-focus;
$input-disabled-bg: $jf-gray-70;
input:disabled {
color: $text-muted;
}
$input-border-color: $jf-gray-60;
$input-placeholder-color: $text-muted;
$form-check-input-bg: $jf-gray-60;
$form-check-input-border: $jf-gray-50;
$form-check-input-checked-color: $jf-blue-focus;
$form-check-input-checked-bg-color: $jf-blue-hover;
$input-group-addon-bg: $input-bg;
$form-select-disabled-color: $jf-text-secondary;
$form-select-disabled-bg: $input-disabled-bg;
$form-select-indicator-color: $jf-gray-50;
$card-bg: $jf-gray-80;
$card-border-color: null;
$tooltip-color: $jf-text-bold;
$tooltip-bg: $jf-gray-50;
$modal-content-bg: $jf-gray-80;
$modal-content-border-color: $jf-gray-50;
$modal-header-border-color: null;
$modal-footer-border-color: null;
$list-group-bg: $card-bg;
$list-group-border-color: $jf-gray-50;
$list-group-hover-bg: $jf-blue-hover;
$list-group-active-bg: $jf-blue-focus;
$list-group-action-color: $jf-text-primary;
$list-group-action-hover-color: $jf-text-bold;
$list-group-action-active-color: $jf-text-bold;
$list-group-action-active-bg: $jf-blue-focus;
// idk why but i had to put these above and below the import
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
@import "../../node_modules/bootstrap4/scss/bootstrap";
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
color: $jf-text-bold;
}
.close {
color: $jf-text-secondary;
}
.close:hover, .close:active {
color: $jf-text-primary;
}
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: $jf-text-bold;
}
.icon-button {
color: $text-muted;
}
.text-bright {
color: $jf-text-bold;
}
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
.nav-link:hover {
background-color: $jf-blue-hover;
}
@import "../base.scss";

View File

@@ -1,19 +0,0 @@
@import "../../node_modules/bootstrap4/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@@ -1,149 +0,0 @@
$jf-blue: rgb(0, 164, 220);
$jf-blue-hover: rgba(0, 164, 220, 0.2);
$jf-blue-focus: rgb(12, 176, 232);
$jf-blue-light: #4bb3dd;
$jf-red: rgb(204, 0, 0);
$jf-red-light: #e12026;
$jf-yellower: #ffc107;
$jf-yellow: #e1b222;
$jf-orange: #ff870f;
$jf-green: #6fbd45;
$jf-green-dark: #008040;
$jf-black: #101010; // 16 16 16
$jf-gray-90: #202020; // 32 32 32
$jf-gray-80: #242424; // jf-card 36 36 36
$jf-gray-70: #292929; // jf-input 41 41 41
$jf-gray-60: #303030; // jf-button 48 48 48
$jf-gray-50: #383838; // jf-button-focus 56 56 56
$jf-text-bold: rgba(255, 255, 255, 0.87);
$jf-text-primary: rgba(255, 255, 255, 0.8);
$jf-text-secondary: rgb(153, 153, 153);
$primary: $jf-blue;
$secondary: $jf-gray-50;
$success: $jf-green-dark;
$danger: $jf-red-light;
$light: $jf-text-primary;
$dark: $jf-gray-90;
$info: $jf-yellow;
$warning: $jf-yellower;
$enable-gradients: false;
$enable-shadows: false;
$enable-rounded: false;
$body-bg: $jf-black;
$body-color: $jf-text-primary;
$border-color: $jf-gray-60;
$component-active-color: $jf-text-bold;
$component-active-bg: $jf-blue-focus;
$text-muted: $jf-text-secondary;
$link-color: $jf-blue-focus;
$btn-link-disabled-color: $jf-text-secondary;
$input-bg: $jf-gray-90;
$input-color: $jf-text-primary;
$input-focus-bg: $jf-gray-60;
$input-focus-border-color: $jf-blue-focus;
$input-disabled-bg: $jf-gray-70;
input:disabled {
color: $text-muted;
}
$input-border-color: $jf-gray-60;
$input-placeholder-color: $text-muted;
$form-check-input-bg: $jf-gray-60;
$form-check-input-border: $jf-gray-50;
$form-check-input-checked-color: $jf-blue-focus;
$form-check-input-checked-bg-color: $jf-blue-hover;
$input-group-addon-bg: $input-bg;
$form-select-disabled-color: $jf-text-secondary;
$form-select-disabled-bg: $input-disabled-bg;
$form-select-indicator-color: $jf-gray-50;
$card-bg: $jf-gray-80;
$card-border-color: null;
$tooltip-color: $jf-text-bold;
$tooltip-bg: $jf-gray-50;
$modal-content-bg: $jf-gray-80;
$modal-content-border-color: $jf-gray-50;
$modal-header-border-color: null;
$modal-footer-border-color: null;
$list-group-bg: $card-bg;
$list-group-border-color: $jf-gray-50;
$list-group-hover-bg: $jf-blue-hover;
$list-group-active-bg: $jf-blue-focus;
$list-group-action-color: $jf-text-primary;
$list-group-action-hover-color: $jf-text-bold;
$list-group-action-active-color: $jf-text-bold;
$list-group-action-active-bg: $jf-blue-focus;
// idk why but i had to put these above and below the import
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
@import "../../node_modules/bootstrap/scss/bootstrap";
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
color: $jf-text-bold;
}
.close {
color: $jf-text-secondary;
}
.close:hover, .close:active {
color: $jf-text-primary;
}
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: $jf-text-bold;
}
.icon-button:active {
color: $text-muted;
}
.text-bright {
color: $jf-text-bold;
}
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
.nav-link:hover {
background-color: $jf-blue-hover;
}
.btn-close {
filter: invert(80%);
}
@import "../base.scss";

View File

@@ -1,19 +0,0 @@
@import "../../node_modules/bootstrap/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
import sass
import subprocess
import shutil
import os
from pathlib import Path
def runcmd(cmd):
if os.name == "nt":
return subprocess.check_output(cmd, shell=True)
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
local_path = Path(__file__).resolve().parent
for bsv in [d for d in local_path.iterdir() if "bs" in d.name]:
scss = [(bsv / f"{bsv.name}-jf.scss"), (bsv / f"{bsv.name}.scss")]
css = [(bsv / f"{bsv.name}-jf.css"), (bsv / f"{bsv.name}.css")]
min_css = [
(bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css"),
(bsv.parents[1] / "data" / "static" / f"{bsv.name}.css"),
]
for i in range(2):
with open(css[i], "w") as f:
f.write(
sass.compile(
filename=str(scss[i].resolve()),
output_style="expanded",
precision=6,
omit_source_map_url=True,
)
)
if css[i].exists():
print(f"{scss[i].name}: Compiled.")
# postcss only excepts forwards slashes? weird.
cssPath = str(css[i].resolve())
if os.name == "nt":
cssPath = cssPath.replace("\\", "/")
runcmd(f"npx postcss {cssPath} --replace --use autoprefixer")
print(f"{scss[i].name}: Prefixed.")
runcmd(
f"npx cleancss --level 1 --format breakWith=lf --output {str(min_css[i].resolve())} {str(css[i].resolve())}"
)
if min_css[i].exists():
print(
f"{scss[i].name}: Minified and copied to {str(min_css[i].resolve())}."
)

View File

@@ -3,7 +3,7 @@ package main
import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jfapi"
"github.com/hrfee/jfa-go/mediabrowser"
)
type testReq struct {
@@ -15,7 +15,7 @@ type testReq struct {
func (app *appContext) TestJF(gc *gin.Context) {
var req testReq
gc.BindJSON(&req)
tempjf, _ := jfapi.NewJellyfin(req.Host, "jfa-go-setup", app.version, "auth", "auth", common.NewTimeoutHandler("authJF", req.Host, true), 30)
tempjf, _ := mediabrowser.NewServer(mediabrowser.JellyfinServer, req.Host, "jfa-go-setup", app.version, "auth", "auth", common.NewTimeoutHandler("authJF", req.Host, true), 30)
_, status, err := tempjf.Authenticate(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.info.Printf("Auth failed with code %d (%s)", status, err)

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

283
static/banner.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 768 B

View File

@@ -4,7 +4,9 @@ import (
"encoding/json"
"io/ioutil"
"log"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -18,9 +20,28 @@ type Storage struct {
lang Lang
}
type EmailLang map[string]map[string]map[string]interface{} // Map of lang codes to email name to fields
func (el *EmailLang) format(lang, email, field string, vals ...string) string {
text := (*el)[lang][email][field].(string)
for _, val := range vals {
text = strings.Replace(text, "{n}", val, 1)
}
return text
}
func (el *EmailLang) get(lang, email, field string) string { return (*el)[lang][email][field].(string) }
type Lang struct {
FormPath string
Form map[string]interface{}
chosenFormLang string
chosenAdminLang string
chosenEmailLang string
AdminPath string
Admin map[string]map[string]interface{}
AdminJSON map[string]string
FormPath string
Form map[string]map[string]interface{}
EmailPath string
Email EmailLang
}
// timePattern: %Y-%m-%dT%H:%M:%S.%f
@@ -57,19 +78,75 @@ func (st *Storage) storeInvites() error {
}
func (st *Storage) loadLang() error {
err := loadJSON(st.lang.FormPath, &st.lang.Form)
loadData := func(path string, stringJson bool) (map[string]string, map[string]map[string]interface{}, error) {
files, err := ioutil.ReadDir(path)
outString := map[string]string{}
out := map[string]map[string]interface{}{}
if err != nil {
return nil, nil, err
}
for _, f := range files {
index := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
var data map[string]interface{}
var file []byte
var err error
file, err = ioutil.ReadFile(filepath.Join(path, f.Name()))
if err != nil {
file = []byte("{}")
}
// Replace Jellyfin with something if necessary
if substituteStrings != "" {
fileString := strings.ReplaceAll(string(file), "Jellyfin", substituteStrings)
file = []byte(fileString)
}
err = json.Unmarshal(file, &data)
if err != nil {
log.Printf("ERROR: Failed to read \"%s\": %s", path, err)
return nil, nil, err
}
if stringJson {
stringJSON, err := json.Marshal(data)
if err != nil {
return nil, nil, err
}
outString[index] = string(stringJSON)
}
out[index] = data
}
return outString, out, nil
}
_, form, err := loadData(st.lang.FormPath, false)
if err != nil {
return err
}
strings := st.lang.Form["strings"].(map[string]interface{})
validationStrings := strings["validationStrings"].(map[string]interface{})
vS, err := json.Marshal(validationStrings)
if err != nil {
return err
for index, lang := range form {
strings := lang["strings"].(map[string]interface{})
validationStrings := strings["validationStrings"].(map[string]interface{})
vS, err := json.Marshal(validationStrings)
if err != nil {
return err
}
strings["validationStrings"] = string(vS)
lang["strings"] = strings
form[index] = lang
}
strings["validationStrings"] = string(vS)
st.lang.Form["strings"] = strings
return nil
st.lang.Form = form
adminJSON, admin, err := loadData(st.lang.AdminPath, true)
st.lang.Admin = admin
st.lang.AdminJSON = adminJSON
_, emails, err := loadData(st.lang.EmailPath, false)
fixedEmails := map[string]map[string]map[string]interface{}{}
for lang, e := range emails {
f := map[string]map[string]interface{}{}
for field, vals := range e {
f[field] = vals.(map[string]interface{})
}
fixedEmails[lang] = f
}
st.lang.Email = fixedEmails
return err
}
func (st *Storage) loadEmails() error {
@@ -190,3 +267,46 @@ func storeJSON(path string, obj interface{}) error {
}
return err
}
// One build of JF 10.7.0 hyphenated user IDs while another one later didn't. These functions will hyphenate/de-hyphenate email storage.
func hyphenate(userID string) string {
if userID[8] == '-' {
return userID
}
return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:]
}
func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil {
return nil, status, err
}
newEmails := map[string]interface{}{}
for _, user := range jfUsers {
unHyphenated := user["Id"].(string)
hyphenated := hyphenate(unHyphenated)
email, ok := old[hyphenated]
if ok {
newEmails[unHyphenated] = email
}
}
return newEmails, status, err
}
func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil {
return nil, status, err
}
newEmails := map[string]interface{}{}
for _, user := range jfUsers {
unstripped := user["Id"].(string)
stripped := strings.ReplaceAll(unstripped, "-", "")
email, ok := old[stripped]
if ok {
newEmails[unstripped] = email
}
}
return newEmails, status, err
}

View File

@@ -1,256 +0,0 @@
import { checkCheckboxes, populateUsers, populateRadios } from "./modules/accounts.js";
import { _post, _get, _delete, rmAttr, addAttr } from "./modules/common.js";
import { populateProfiles } from "./modules/settings.js";
import { Focus, Unfocus, createEl, storeDefaults } from "./modules/admin.js";
interface aWindow extends Window {
changeEmail(icon: HTMLElement, id: string): void;
}
declare var window: aWindow;
const validateEmail = (email: string): boolean => /\S+@\S+\.\S+/.test(email);
window.changeEmail = (icon: HTMLElement, id: string): void => {
const iconContent = icon.outerHTML;
icon.setAttribute('class', '');
const entry = icon.nextElementSibling as HTMLInputElement;
const ogEmail = entry.value;
entry.readOnly = false;
entry.classList.remove('form-control-plaintext');
entry.classList.add('form-control');
if (ogEmail == "") {
entry.placeholder = 'Address';
}
const tick = createEl(`
<i class="fa fa-check d-inline-block icon-button text-success" style="margin-left: 0.5rem; margin-right: 0.5rem;"></i>
`);
tick.onclick = (): void => {
const newEmail = entry.value;
if (!validateEmail(newEmail) || newEmail == ogEmail) {
return;
}
cross.remove();
const spinner = createEl(`
<div class="spinner-border spinner-border-sm" role="status" style="width: 1rem; height: 1rem; margin-left: 0.5rem;">
<span class="sr-only">Saving...</span>
</div>
`);
tick.replaceWith(spinner);
let send = {};
send[id] = newEmail;
_post("/users/emails", send, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
entry.nextElementSibling.remove();
} else {
entry.value = ogEmail;
}
}
});
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
};
const cross = createEl(`
<i class="fa fa-close d-inline-block icon-button text-danger"></i>
`);
cross.onclick = (): void => {
tick.remove();
cross.remove();
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
entry.value = ogEmail;
};
icon.parentNode.appendChild(tick);
icon.parentNode.appendChild(cross);
};
(<HTMLInputElement>document.getElementById('selectAll')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = (<HTMLInputElement>this).checked;
}
checkCheckboxes();
};
(<HTMLInputElement>document.getElementById('deleteModalNotify')).onclick = function (): void {
const textbox: HTMLElement = document.getElementById('deleteModalReasonBox');
if ((<HTMLInputElement>this).checked) {
Focus(textbox);
} else {
Unfocus(textbox);
}
};
(<HTMLButtonElement>document.getElementById('accountsTabDelete')).onclick = function (): void {
const deleteButton = this as HTMLButtonElement;
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let selected: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++) {
selected[i] = checkboxes[i].id.replace("select_", "");
}
let title = " user";
let msg = "Notify user";
if (selected.length > 1) {
title += "s";
msg += "s";
}
title = `Delete ${selected.length} ${title}`;
msg += " of account deletion";
document.getElementById('deleteModalTitle').textContent = title;
const dmNotify = document.getElementById('deleteModalNotify') as HTMLInputElement;
dmNotify.checked = false;
document.getElementById('deleteModalNotifyLabel').textContent = msg;
const dmReason = document.getElementById('deleteModalReason') as HTMLTextAreaElement;
dmReason.value = '';
Unfocus(document.getElementById('deleteModalReasonBox'));
const dmSend = document.getElementById('deleteModalSend') as HTMLButtonElement;
dmSend.textContent = 'Delete';
dmSend.onclick = function (): void {
const button = this as HTMLButtonElement;
const send = {
'users': selected,
'notify': dmNotify.checked,
'reason': dmReason.value
};
_delete("/users", send, function (): void {
if (this.readyState == 4) {
if (this.status == 500) {
if ("error" in this.reponse) {
button.textContent = 'Failed';
} else {
button.textContent = 'Partial fail (check console)';
console.log(this.response);
}
setTimeout((): void => {
Unfocus(deleteButton);
window.Modals.delete.hide();
}, 4000);
} else {
Unfocus(deleteButton);
window.Modals.delete.hide()
}
populateUsers();
checkCheckboxes();
}
});
};
window.Modals.delete.show();
};
(<HTMLInputElement>document.getElementById('selectAll')).checked = false;
(<HTMLButtonElement>document.getElementById('accountsTabSetDefaults')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let userIDs: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++){
userIDs[i] = checkboxes[i].id.replace("select_", "");
}
if (userIDs.length == 0) {
return;
}
populateRadios();
let userString = 'user';
if (userIDs.length > 1) {
userString += "s";
}
populateProfiles(true);
const profileSelect = document.getElementById('profileSelect') as HTMLSelectElement;
profileSelect.textContent = '';
for (let i = 0; i < window.availableProfiles.length; i++) {
profileSelect.innerHTML += `
<option value="${window.availableProfiles[i]}" ${(i == 0) ? "selected" : ""}>${window.availableProfiles[i]}</option>
`;
}
document.getElementById('defaultsTitle').textContent = `Apply settings to ${userIDs.length} ${userString}`;
document.getElementById('userDefaultsDescription').textContent = `
Apply settings from an existing profile or source settings from a user.
`;
document.getElementById('storeHomescreenLabel').textContent = `Apply homescreen layout`;
Focus(document.getElementById('defaultsSourceSection'));
(<HTMLSelectElement>document.getElementById('defaultsSource')).value = 'profile';
Focus(document.getElementById('profileSelectBox'));
Unfocus(document.getElementById('defaultUserRadiosBox'));
Unfocus(document.getElementById('newProfileBox'));
document.getElementById('storeDefaults').onclick = (): void => storeDefaults(userIDs);
window.Modals.userDefaults.show();
};
(<HTMLSelectElement>document.getElementById('defaultsSource')).addEventListener('change', function (): void {
const radios = document.getElementById('defaultUserRadiosBox');
const profileBox = document.getElementById('profileSelectBox');
if (this.value == 'profile') {
Unfocus(radios);
Focus(profileBox);
} else {
Unfocus(profileBox);
Focus(radios);
}
});
(<HTMLButtonElement>document.getElementById('newUserCreate')).onclick = function (): void {
const button = this as HTMLButtonElement;
const ogText = button.textContent;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating...
`;
const email: string = (<HTMLInputElement>document.getElementById('newUserEmail')).value;
var username: string = email;
if (document.getElementById('newUserName') != null) {
username = (<HTMLInputElement>document.getElementById('newUserName')).value;
}
const password: string = (<HTMLInputElement>document.getElementById('newUserPassword')).value;
if (!validateEmail(email) && email != "") {
return;
}
const send = {
'username': username,
'password': password,
'email': email
};
_post("/users", send, function (): void {
if (this.readyState == 4) {
rmAttr(button, 'btn-primary');
if (this.status == 200) {
addAttr(button, 'btn-success');
button.textContent = 'Success';
setTimeout((): void => {
rmAttr(button, 'btn-success');
addAttr(button, 'btn-primary');
button.textContent = ogText;
window.Modals.newUser.hide();
}, 1000);
populateUsers();
} else {
addAttr(button, 'btn-danger');
if ("error" in this.response) {
button.textContent = this.response["error"];
} else {
button.textContent = 'Failed';
}
setTimeout((): void => {
rmAttr(button, 'btn-danger');
addAttr(button, 'btn-primary');
button.textContent = ogText;
}, 2000);
populateUsers();
}
}
});
};
(<HTMLButtonElement>document.getElementById('accountsTabAddUser')).onclick = function (): void {
(<HTMLInputElement>document.getElementById('newUserEmail')).value = '';
(<HTMLInputElement>document.getElementById('newUserPassword')).value = '';
if (document.getElementById('newUserName') != null) {
(<HTMLInputElement>document.getElementById('newUserName')).value = '';
}
window.Modals.newUser.show();
};

View File

@@ -1,130 +1,122 @@
import { serializeForm, rmAttr, addAttr, _get, _post, _delete } from "./modules/common.js";
import { Focus, Unfocus } from "./modules/admin.js";
import { toggleCSS } from "./modules/animation.js";
import { populateUsers, checkCheckboxes } from "./modules/accounts.js";
import { generateInvites, addOptions, checkDuration } from "./modules/invites.js";
import { showSetting, openSettings } from "./modules/settings.js";
import { BS4 } from "./modules/bs4.js";
import { BS5 } from "./modules/bs5.js";
import "./accounts.js";
import "./settings.js";
import { toggleTheme, loadTheme } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js";
import { Tabs } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js";
interface aWindow extends Window {
toClipboard(str: string): void;
}
loadTheme();
(document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme;
declare var window: aWindow;
window.lang = new lang(window.langFile as LangFile);
loadLangSelector("admin");
// _get(`/lang/admin/${window.language}.json`, null, (req: XMLHttpRequest) => {
// if (req.readyState == 4 && req.status == 200) {
// langLoaded = true;
// window.lang = new lang(req.response as LangFile);
// }
// });
interface TabSwitcher {
els: Array<HTMLDivElement>;
tabButtons: Array<HTMLAnchorElement>;
focus: (el: number) => void;
invites: () => void;
accounts: () => void;
settings: () => void;
}
window.animationEvent = whichAnimationEvent();
const tabs: TabSwitcher = {
els: [document.getElementById('invitesTab') as HTMLDivElement, document.getElementById('accountsTab') as HTMLDivElement, document.getElementById('settingsTab') as HTMLDivElement],
tabButtons: [document.getElementById('invitesTabButton') as HTMLAnchorElement, document.getElementById('accountsTabButton') as HTMLAnchorElement, document.getElementById('settingsTabButton') as HTMLAnchorElement],
focus: (el: number): void => {
for (let i = 0; i < tabs.els.length; i++) {
if (i == el) {
Focus(tabs.els[i]);
addAttr(tabs.tabButtons[i], "active");
} else {
Unfocus(tabs.els[i]);
rmAttr(tabs.tabButtons[i], "active");
}
}
},
invites: (): void => tabs.focus(0),
accounts: (): void => {
populateUsers();
(document.getElementById('selectAll') as HTMLInputElement).checked = false;
checkCheckboxes();
tabs.focus(1);
},
settings: (): void => openSettings(document.getElementById('settingsSections'), document.getElementById('settingsContent'), (): void => {
window.BS.triggerTooltips();
showSetting("ui");
tabs.focus(2);
})
};
window.token = "";
window.bsVersion = window.bs5 ? 5 : 4
window.availableProfiles = window.availableProfiles || [];
if (window.bs5) {
window.BS = new BS5;
} else {
window.BS = new BS4;
window.BS.Compat();
}
// load modals
(() => {
window.modals = {} as Modals;
window.Modals = {} as BSModals;
window.modals.login = new Modal(document.getElementById('modal-login'), true);
window.Modals.login = window.BS.newModal('login');
window.Modals.userDefaults = window.BS.newModal('userDefaults');
window.Modals.users = window.BS.newModal('users');
window.Modals.restart = window.BS.newModal('restartModal');
window.Modals.refresh = window.BS.newModal('refreshModal');
window.Modals.about = window.BS.newModal('aboutModal');
window.Modals.delete = window.BS.newModal('deleteModal');
window.Modals.newUser = window.BS.newModal('newUserModal');
window.modals.addUser = new Modal(document.getElementById('modal-add-user'));
tabs.tabButtons[0].onclick = tabs.invites;
tabs.tabButtons[1].onclick = tabs.accounts;
tabs.tabButtons[2].onclick = tabs.settings;
window.modals.about = new Modal(document.getElementById('modal-about'));
(document.getElementById('setting-about') as HTMLSpanElement).onclick = window.modals.about.toggle;
tabs.invites();
window.modals.modifyUser = new Modal(document.getElementById('modal-modify-user'));
// Predefined colors for the theme button.
var buttonColor: string = "custom";
if (window.cssFile.includes("jf")) {
buttonColor = "rgb(255,255,255)";
} else if (window.cssFile == ("bs" + window.bsVersion + ".css")) {
buttonColor = "rgb(16,16,16)";
}
window.modals.deleteUser = new Modal(document.getElementById('modal-delete-user'));
if (buttonColor != "custom") {
const switchButton = document.createElement('button') as HTMLButtonElement;
switchButton.classList.add('btn', 'btn-secondary');
switchButton.innerHTML = `
Theme
<i class="fa fa-circle circle" style="color: ${buttonColor}; margin-left: 0.4rem;" id="fakeButton"></i>
`;
switchButton.onclick = (): void => toggleCSS(document.getElementById('fakeButton'));
document.getElementById('headerButtons').appendChild(switchButton);
}
window.modals.settingsRestart = new Modal(document.getElementById('modal-restart'));
var availableProfiles: Array<string>;
window.modals.settingsRefresh = new Modal(document.getElementById('modal-refresh'));
window["token"] = "";
window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults'));
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close);
window.toClipboard = (str: string): void => {
const el = document.createElement('textarea') as HTMLTextAreaElement;
el.value = str;
el.readOnly = true;
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
el.select();
document.execCommand("copy");
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
})();
var inviteCreator = new createInvite();
var accounts = new accountsList();
window.invites = new inviteList();
var settings = new settingsList();
var profiles = new ProfileEditor();
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
/*const modifySettingsSource = function () {
const profile = document.getElementById('radio-use-profile') as HTMLInputElement;
const user = document.getElementById('radio-use-user') as HTMLInputElement;
const profileSelect = document.getElementById('modify-user-profiles') as HTMLDivElement;
const userSelect = document.getElementById('modify-user-users') as HTMLDivElement;
(user.nextElementSibling as HTMLSpanElement).classList.toggle('!normal');
(user.nextElementSibling as HTMLSpanElement).classList.toggle('!high');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('!normal');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('!high');
profileSelect.classList.toggle('unfocused');
userSelect.classList.toggle('unfocused');
}*/
// load tabs
window.tabs = new Tabs();
window.tabs.addTab("invites", null, window.invites.reload);
window.tabs.addTab("accounts", null, accounts.reload);
window.tabs.addTab("settings", null, settings.reload);
for (let tab of ["invites", "accounts", "settings"]) {
if (window.location.pathname == "/" + tab) {
window.tabs.switch(tab, true);
}
}
function login(username: string, password: string, modal: boolean, button?: HTMLButtonElement, run?: (arg0: number) => void): void {
if (window.location.pathname == "/") {
window.tabs.switch("invites", true);
}
document.addEventListener("tab-change", (event: CustomEvent) => {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get('lang');
let tab = "/" + event.detail;
if (tab == "/invites") {
if (window.location.pathname == "/") {
tab = "/";
} else { tab = "../"; }
}
if (lang) {
tab += "?lang=" + lang
}
window.history.replaceState("", "Admin - jfa-go", tab);
});
function login(username: string, password: string, run?: (state?: number) => void) {
const req = new XMLHttpRequest();
req.responseType = 'json';
let url = "/token/login";
let url = window.URLBase;
const refresh = (username == "" && password == "");
if (refresh) {
url = "/token/refresh";
url += "/token/refresh";
} else {
url += "/token/login";
}
req.open("GET", url, true);
if (!refresh) {
@@ -133,77 +125,63 @@ function login(username: string, password: string, modal: boolean, button?: HTML
req.onreadystatechange = function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
let errorMsg = this.response["error"];
if (!errorMsg) {
errorMsg = "Unknown error";
let errorMsg = window.lang.notif("errorConnection");
if (this.response) {
errorMsg = this.response["error"];
}
if (modal) {
button.disabled = false;
button.textContent = errorMsg;
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.textContent = "Login";
}, 4000);
if (!errorMsg) {
errorMsg = window.lang.notif("errorUnknown");
}
if (!refresh) {
window.notifications.customError("loginError", errorMsg);
} else {
window.Modals.login.show();
window.modals.login.show();
}
} else {
const data = this.response;
window.token = data["token"];
generateInvites();
setInterval((): void => generateInvites(), 60 * 1000);
addOptions(30, document.getElementById('days') as HTMLSelectElement);
addOptions(24, document.getElementById('hours') as HTMLSelectElement);
const minutes = document.getElementById('minutes') as HTMLSelectElement;
addOptions(59, minutes);
minutes.value = "30";
checkDuration();
if (modal) {
window.Modals.login.hide();
window.modals.login.close();
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {
case "invites":
window.invites.reload();
break;
case "accounts":
accounts.reload();
break;
case "settings":
settings.reload();
break;
}
Focus(document.getElementById('logoutButton'));
}
if (run) {
run(+this.status);
document.getElementById("logout-button").classList.remove("unfocused");
}
if (run) { run(+this.status); }
}
};
req.send();
}
(document.getElementById('loginForm') as HTMLFormElement).onsubmit = function (): boolean {
window.token = "";
const details = serializeForm('loginForm');
const button = document.getElementById('loginSubmit') as HTMLButtonElement;
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
login(details["username"], details["password"], true, button);
return false;
(document.getElementById('form-login') as HTMLFormElement).onsubmit = (event: SubmitEvent) => {
event.preventDefault();
const button = (event.target as HTMLElement).querySelector(".submit") as HTMLSpanElement;
const username = (document.getElementById("login-user") as HTMLInputElement).value;
const password = (document.getElementById("login-password") as HTMLInputElement).value;
if (!username || !password) {
window.notifications.customError("loginError", window.lang.notif("errorLoginBlank"));
return;
}
toggleLoader(button);
login(username, password, () => toggleLoader(button));
};
generateInvites(true);
login("", "");
login("", "", false, null, (status: number): void => {
if (!(status == 200 || status == 204)) {
window.Modals.login.show();
(document.getElementById('logout-button') as HTMLButtonElement).onclick = () => _post("/logout", null, (req: XMLHttpRequest): boolean => {
if (req.readyState == 4 && req.status == 200) {
window.token = "";
location.reload();
return false;
}
});
(document.getElementById('logoutButton') as HTMLButtonElement).onclick = function (): void {
_post("/logout", null, function (): boolean {
if (this.readyState == 4 && this.status == 200) {
window.token = "";
location.reload();
return false;
}
});
};

View File

@@ -1,29 +1,37 @@
import { serializeForm, _post, _get, _delete, addAttr, rmAttr } from "./modules/common.js";
import { BS5 } from "./modules/bs5.js";
import { BS4 } from "./modules/bs4.js";
import { Modal } from "./modules/modal.js";
import { _get, _post, toggleLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
interface formWindow extends Window {
usernameEnabled: boolean;
validationStrings: pwValStrings;
checkPassword(): void;
invalidPassword: string;
modal: Modal;
code: string;
}
declare var window: formWindow;
interface pwValString {
singular: string;
plural: string;
}
interface pwValStrings {
length, uppercase, lowercase, number, special: pwValString;
length: pwValString;
uppercase: pwValString;
lowercase: pwValString;
number: pwValString;
special: pwValString;
[ type: string ]: pwValString;
}
loadLangSelector("form");
window.modal = new Modal(document.getElementById("modal-success"));
declare var window: formWindow;
var defaultPwValStrings: pwValStrings = {
length: {
singular: "Must have at least {n} character",
plural: "Must have a least {n} characters"
plural: "Must have at least {n} characters"
},
uppercase: {
singular: "Must have at least {n} uppercase character",
@@ -43,111 +51,163 @@ var defaultPwValStrings: pwValStrings = {
}
}
const toggleSpinner = (ogText?: string): string => {
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
if (document.getElementById('createAccountSpinner')) {
submitButton.innerHTML = ogText ? ogText : `<span>Create Account</span>`;
submitButton.disabled = false;
return "";
const form = document.getElementById("form-create") as HTMLFormElement;
const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement;
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
let usernameField = document.getElementById("create-username") as HTMLInputElement;
const emailField = document.getElementById("create-email") as HTMLInputElement;
if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameField = emailField; }
const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
const checkPasswords = () => {
if (passwordField.value != rePasswordField.value) {
rePasswordField.setCustomValidity(window.invalidPassword);
submitButton.disabled = true;
submitSpan.setAttribute("disabled", "");
} else {
let ogText = submitButton.innerHTML;
submitButton.innerHTML = `
<span id="createAccountSpinner" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>Creating...
`;
return ogText;
rePasswordField.setCustomValidity("");
submitButton.disabled = false;
submitSpan.removeAttribute("disabled");
}
};
rePasswordField.addEventListener("keyup", checkPasswords);
passwordField.addEventListener("keyup", checkPasswords);
for (let key in window.validationStrings) {
if (window.validationStrings[key].singular == "" || !(window.validationStrings[key].plural.includes("{n}"))) {
window.validationStrings[key].singular = defaultPwValStrings[key].singular;
}
if (window.validationStrings[key].plural == "" || !(window.validationStrings[key].plural.includes("{n}"))) {
window.validationStrings[key].plural = defaultPwValStrings[key].plural;
}
let el = document.getElementById(key) as HTMLUListElement;
if (el) {
const min: number = +el.getAttribute("min");
let text = "";
if (min == 1) {
text = window.validationStrings[key].singular.replace("{n}", "1");
} else {
text = window.validationStrings[key].plural.replace("{n}", min.toString());
}
(document.getElementById(key).children[0] as HTMLDivElement).textContent = text;
}
interface respDTO {
[ type: string ]: boolean;
}
window.BS = window.bs5 ? new BS5 : new BS4;
var successBox: BSModal = window.BS.newModal('successBox');;
interface sendDTO {
code: string;
email: string;
username: string;
password: string;
}
var code = window.location.href.split('/').pop();
(document.getElementById('accountForm') as HTMLFormElement).addEventListener('submit', (event: any): boolean => {
const create = (event: SubmitEvent) => {
event.preventDefault();
const el = document.getElementById('errorMessage');
if (el) {
el.remove();
}
const ogText = toggleSpinner();
let send: Object = serializeForm('accountForm');
send["code"] = code;
if (!window.usernameEnabled) {
send["email"] = send["username"];
}
_post("/newUser", send, function (): void {
if (this.readyState == 4) {
toggleSpinner(ogText);
let data: Object = this.response;
const errorGiven = ("error" in data)
if (errorGiven || data["success"] === false) {
let errorMessage = "Unknown Error";
if (errorGiven && errorGiven != true) {
errorMessage = data["error"];
}
document.getElementById('errorBox').innerHTML += `
<button id="errorMessage" class="btn btn-outline-danger" disabled>${errorMessage}</button>
`;
toggleLoader(submitSpan);
let send: sendDTO = {
code: window.code,
username: usernameField.value,
email: emailField.value,
password: passwordField.value
};
_post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let vals = JSON.parse(req.response) as respDTO;
let valid = true;
for (let type in vals) {
if (requirements[type]) { requirements[type].valid = vals[type]; }
if (!vals[type]) { valid = false; }
}
toggleLoader(submitSpan);
if (req.status == 200 && valid) {
window.modal.show();
} else {
let valid = true;
for (let key in data) {
if (data.hasOwnProperty(key)) {
const criterion = document.getElementById(key);
if (criterion) {
if (data[key] === false) {
valid = false;
addAttr(criterion, "list-group-item-danger");
rmAttr(criterion, "list-group-item-success");
} else {
addAttr(criterion, "list-group-item-success");
rmAttr(criterion, "list-group-item-danger");
}
}
}
}
if (valid) {
successBox.show();
}
submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge");
setTimeout(() => {
submitSpan.classList.add("~urge");
submitSpan.classList.remove("~critical");
}, 1000);
}
}
}, true);
return false;
});
};
form.onsubmit = create;
class Requirement {
private _name: string;
protected _minCount: number;
private _content: HTMLSpanElement;
private _valid: HTMLSpanElement;
private _li: HTMLLIElement;
get valid(): boolean { return this._valid.classList.contains("~positive"); }
set valid(state: boolean) {
if (state) {
this._valid.classList.add("~positive");
this._valid.classList.remove("~critical");
this._valid.innerHTML = `<i class="icon ri-check-line" title="valid"></i>`;
} else {
this._valid.classList.add("~critical");
this._valid.classList.remove("~positive");
this._valid.innerHTML = `<i class="icon ri-close-line" title="invalid"></i>`;
}
}
constructor(name: string, el: HTMLLIElement) {
this._name = name;
this._li = el;
this._content = this._li.querySelector("span.requirement-content") as HTMLSpanElement;
this._valid = this._li.querySelector("span.requirement-valid") as HTMLSpanElement;
this.valid = false;
this._minCount = +this._li.getAttribute("min");
let text = "";
if (this._minCount == 1) {
text = window.validationStrings[this._name].singular.replace("{n}", "1");
} else {
text = window.validationStrings[this._name].plural.replace("{n}", ""+this._minCount);
}
this._content.textContent = text;
}
validate = (count: number) => { this.valid = (count >= this._minCount); }
}
// Incredible code right here
const isInt = (s: string): boolean => { return (s in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); }
const testStrings = (f: pwValString): boolean => {
const testString = (s: string): boolean => {
if (s == "" || !s.includes("{n}")) { return false; }
return true;
}
return testString(f.singular) && testString(f.plural);
}
interface Validation { [name: string]: number }
const validate = (s: string): Validation => {
let v: Validation = {};
for (let criteria of ["length", "lowercase", "uppercase", "number", "special"]) { v[criteria] = 0; }
v["length"] = s.length;
for (let c of s) {
if (isInt(c)) { v["number"]++; }
else {
const upper = c.toUpperCase();
if (upper == c.toLowerCase()) { v["special"]++; }
else {
if (upper == c) { v["uppercase"]++; }
else if (upper != c) { v["lowercase"]++; }
}
}
}
return v
}
passwordField.addEventListener("keyup", () => {
const v = validate(passwordField.value);
for (let criteria in requirements) {
requirements[criteria].validate(v[criteria]);
}
});
window.checkPassword = (): void => {
const entry = document.getElementById('inputPassword') as HTMLInputElement;
if (entry.value != "") {
const reentry = document.getElementById('reInputPassword') as HTMLInputElement;
const identical = (entry.value == reentry.value);
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
if (identical) {
reentry.setCustomValidity('');
rmAttr(submitButton, "btn-outline-danger");
addAttr(submitButton, "btn-outline-primary");
} else {
reentry.setCustomValidity(window.invalidPassword);
addAttr(submitButton, "btn-outline-danger");
rmAttr(submitButton, "btn-outline-primary");
var requirements: { [category: string]: Requirement} = {};
if (!window.validationStrings) {
window.validationStrings = defaultPwValStrings;
} else {
for (let category in window.validationStrings) {
if (!testStrings(window.validationStrings[category])) {
window.validationStrings[category] = defaultPwValStrings[category];
}
const el = document.getElementById("requirement-" + category);
if (el) {
requirements[category] = new Requirement(category, el as HTMLLIElement);
}
}
}

View File

@@ -1,81 +0,0 @@
import { serializeForm, rmAttr, addAttr, _get, _post, _delete } from "./modules/common.js";
import { generateInvites, checkDuration } from "./modules/invites.js";
interface aWindow extends Window {
setProfile(el: HTMLElement): void;
}
declare var window: aWindow;
function fixCheckboxes(): void {
const send_to_address: Array<HTMLInputElement> = [document.getElementById('send_to_address') as HTMLInputElement, document.getElementById('send_to_address_enabled') as HTMLInputElement];
if (send_to_address[0] != null) {
send_to_address[0].disabled = !send_to_address[1].checked;
}
const multiUseEnabled = document.getElementById('multiUseEnabled') as HTMLInputElement;
const multiUseCount = document.getElementById('multiUseCount') as HTMLInputElement;
const noUseLimit = document.getElementById('noUseLimit') as HTMLInputElement;
multiUseCount.disabled = !multiUseEnabled.checked;
noUseLimit.checked = false;
noUseLimit.disabled = !multiUseEnabled.checked;
}
fixCheckboxes();
(document.getElementById('inviteForm') as HTMLFormElement).onsubmit = function (): boolean {
const button = document.getElementById('generateSubmit') as HTMLButtonElement;
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
let send = serializeForm('inviteForm');
send["remaining-uses"] = +send["remaining-uses"];
if (!send['multiple-uses'] || send['no-limit']) {
delete send['remaining-uses'];
}
if (send["profile"] == "NoProfile") {
send["profile"] = "";
}
const sendToAddress: any = document.getElementById('send_to_address');
const sendToAddressEnabled: any = document.getElementById('send_to_address_enabled');
if (sendToAddress && sendToAddressEnabled) {
send['email'] = send['send_to_address'];
delete send['send_to_address'];
delete send['send_to_address_enabled'];
}
_post("/invites", send, function (): void {
if (this.readyState == 4) {
button.textContent = 'Generate';
button.disabled = false;
generateInvites();
}
});
return false;
};
window.BS.triggerTooltips();
window.setProfile= (select: HTMLSelectElement): void => {
if (!select.value) {
return;
}
let val = select.value;
if (select.value == "NoProfile") {
val = ""
}
const invite = select.id.replace("profile_", "");
const send = {
"invite": invite,
"profile": val
};
_post("/invites/profile", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
generateInvites();
}
});
}
const nE: Array<string> = ["days", "hours", "minutes"];
for (const i in nE) {
document.getElementById(nE[i]).addEventListener("change", checkDuration);
}

View File

@@ -1,105 +1,405 @@
import { _get, _post, _delete } from "../modules/common.js";
import { Focus, Unfocus } from "../modules/admin.js";
import { _get, _post, _delete, toggleLoader } from "../modules/common.js";
interface aWindow extends Window {
checkCheckboxes: () => void;
interface User {
id: string;
name: string;
email: string | undefined;
last_active: string;
admin: boolean;
}
declare var window: aWindow;
class user implements User {
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
private _username: HTMLSpanElement;
private _admin: HTMLSpanElement;
private _email: HTMLInputElement;
private _emailAddress: string;
private _emailEditButton: HTMLElement;
private _lastActive: HTMLTableDataCellElement;
id: string;
private _selected: boolean;
export const checkCheckboxes = (): void => {
const defaultsButton = document.getElementById('accountsTabSetDefaults');
const deleteButton = document.getElementById('accountsTabDelete');
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let checked = checkboxes.length;
if (checked == 0) {
Unfocus(defaultsButton);
Unfocus(deleteButton);
} else {
Focus(defaultsButton);
Focus(deleteButton);
if (checked == 1) {
deleteButton.textContent = 'Delete User';
get selected(): boolean { return this._selected; }
set selected(state: boolean) {
this._selected = state;
this._check.checked = state;
state ? document.dispatchEvent(this._checkEvent) : document.dispatchEvent(this._uncheckEvent);
}
get name(): string { return this._username.textContent; }
set name(value: string) { this._username.textContent = value; }
get admin(): boolean { return this._admin.classList.contains("chip"); }
set admin(state: boolean) {
if (state) {
this._admin.classList.add("chip", "~info", "ml-1");
this._admin.textContent = "Admin";
} else {
deleteButton.textContent = 'Delete Users';
this._admin.classList.remove("chip", "~info", "ml-1");
this._admin.textContent = ""
}
}
}
window.checkCheckboxes = checkCheckboxes;
get email(): string { return this._emailAddress; }
set email(value: string) { this._email.value = value; this._emailAddress = value; }
get last_active(): string { return this._lastActive.textContent; }
set last_active(value: string) { this._lastActive.textContent = value; }
export function populateUsers(): void {
const acList = document.getElementById('accountsList');
acList.innerHTML = `
<div class="d-flex align-items-center">
<strong>Getting Users...</strong>
<div class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
</div>
`;
Unfocus(acList.parentNode.querySelector('thead'));
const accountsList = document.createElement('tbody');
accountsList.id = 'accountsList';
const generateEmail = (id: string, name: string, email: string): string => {
let entry: HTMLDivElement = document.createElement('div');
entry.id = 'email_' + id;
let emailValue: string = email;
if (emailValue == undefined) {
emailValue = "";
}
entry.innerHTML = `
<i class="fa fa-edit d-inline-block icon-button" style="margin-right: 2%;" onclick="changeEmail(this, '${id}')"></i>
<input type="email" class="form-control-plaintext form-control-sm text-muted d-inline-block addressText" id="address_${id}" style="width: auto;" value="${emailValue}" readonly>
private _checkEvent = new CustomEvent("accountCheckEvent");
private _uncheckEvent = new CustomEvent("accountUncheckEvent");
constructor(user: User) {
this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.innerHTML = `
<td><input type="checkbox" value=""></td>
<td><span class="accounts-username"></span> <span class="accounts-admin"></span></td>
<td><i class="icon ri-edit-line accounts-email-edit"></i><input type="email" class="input ~neutral !normal stealth-input stealth-input-hidden accounts-email" readonly></td>
<td class="accounts-last-active"></td>
`;
return entry.outerHTML;
};
const template = (id: string, username: string, email: string, lastActive: string, admin: boolean): string => {
let isAdmin = "No";
if (admin) {
isAdmin = "Yes";
}
let fci = "form-check-input";
if (window.bsVersion != 5) {
fci = "";
}
return `
<td nowrap="nowrap" class="align-middle" scope="row"><input class="${fci}" type="checkbox" value="" id="select_${id}" onclick="checkCheckboxes();"></td>
<td nowrap="nowrap" class="align-middle">${username}${admin ? '<span style="margin-left: 1rem;" class="badge rounded-pill bg-info text-dark">Admin</span>' : ''}</td>
<td nowrap="nowrap" class="align-middle">${generateEmail(id, name, email)}</td>
<td nowrap="nowrap" class="align-middle">${lastActive}</td>
`;
};
this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email") as HTMLInputElement;
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; }
_get("/users", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
window.jfUsers = this.response['users'];
for (const user of window.jfUsers) {
let tr = document.createElement('tr');
tr.innerHTML = template(user['id'], user['name'], user['email'], user['last_active'], user['admin']);
accountsList.appendChild(tr);
const toggleStealthInput = () => {
this._email.classList.toggle("stealth-input-hidden");
this._email.readOnly = !this._email.readOnly;
this._emailEditButton.classList.toggle("ri-check-line");
this._emailEditButton.classList.toggle("ri-edit-line");
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (this._email.contains(event.target) || this._emailEditButton.contains(event.target)))) {
toggleStealthInput();
this.email = this.email;
document.removeEventListener("click", outerClickListener);
}
Focus(acList.parentNode.querySelector('thead'));
acList.replaceWith(accountsList);
}
});
}
};
this._emailEditButton.onclick = () => {
if (this._email.classList.contains("stealth-input-hidden")) {
document.addEventListener('click', outerClickListener);
} else {
this._updateEmail();
document.removeEventListener('click', outerClickListener);
}
toggleStealthInput();
};
export function populateRadios(): void {
const radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
let first = true;
for (const i in window.jfUsers) {
const user = window.jfUsers[i];
const radio = document.createElement('div');
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="defaultRadios" id="default_${user['id']}" ${checked}>
<label class="form-check-label" for="default_${user['id']}">${user['name']}</label>`;
radioList.appendChild(radio);
this.update(user);
}
}
private _updateEmail = () => {
let oldEmail = this.email;
this.email = this._email.value;
let send = {};
send[this.id] = this.email;
_post("/users/emails", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
window.notifications.customSuccess("emailChanged", window.lang.var("notifications", "changedEmailAddress", `"${this.name}"`));
} else {
this.email = oldEmail;
window.notifications.customError("emailChanged", window.lang.var("notifications", "errorChangedEmailAddress", `"${this.name}"`));
}
}
});
}
update = (user: User) => {
this.id = user.id;
this.name = user.name;
this.email = user.email || "";
this.last_active = user.last_active;
this.admin = user.admin;
}
asElement = (): HTMLTableRowElement => { return this._row; }
remove = () => {
if (this.selected) {
document.dispatchEvent(this._uncheckEvent);
}
this._row.remove();
}
}
export class accountsList {
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;
private _profileSelect = document.getElementById("modify-user-profiles") as HTMLSelectElement;
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _checkCount: number = 0;
private _addUserForm = document.getElementById("form-add-user") as HTMLFormElement;
private _addUserName = this._addUserForm.querySelector("input[type=text]") as HTMLInputElement;
private _addUserEmail = this._addUserForm.querySelector("input[type=email]") as HTMLInputElement;
private _addUserPassword = this._addUserForm.querySelector("input[type=password]") as HTMLInputElement;
get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) {
for (let id in this._users) {
this._users[id].selected = state;
}
this._selectAll.checked = state;
this._selectAll.indeterminate = false;
state ? this._checkCount = Object.keys(this._users).length : 0;
}
add = (u: User) => {
let domAccount = new user(u);
this._users[u.id] = domAccount;
this._table.appendChild(domAccount.asElement());
}
private _checkCheckCount = () => {
if (this._checkCount == 0) {
this._selectAll.indeterminate = false;
this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused");
this._deleteUser.classList.add("unfocused");
} else {
if (this._checkCount == Object.keys(this._users).length) {
this._selectAll.checked = true;
this._selectAll.indeterminate = false;
} else {
this._selectAll.checked = false;
this._selectAll.indeterminate = true;
}
this._modifySettings.classList.remove("unfocused");
this._deleteUser.classList.remove("unfocused");
this._deleteUser.textContent = window.lang.quantity("deleteUser", this._checkCount);
}
}
private _collectUsers = (): string[] => {
let list: string[] = [];
for (let id in this._users) {
if (this._users[id].selected) { list.push(id); }
}
return list;
}
private _addUser = (event: Event) => {
event.preventDefault();
const button = this._addUserForm.querySelector("span.submit") as HTMLSpanElement;
const send = {
"username": this._addUserName.value,
"email": this._addUserEmail.value,
"password": this._addUserPassword.value
};
for (let field in send) {
if (!send[field]) {
window.notifications.customError("addUserBlankField", window.lang.notif("errorBlankFields"));
return;
}
}
toggleLoader(button);
_post("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200) {
window.notifications.customSuccess("addUser", window.lang.var("notifications", "userCreated", `"${send['username']}"`));
}
this.reload();
window.modals.addUser.close();
}
});
}
deleteUsers = () => {
const modalHeader = document.getElementById("header-delete-user");
modalHeader.textContent = window.lang.quantity("deleteNUsers", this._checkCount);
let list = this._collectUsers();
const form = document.getElementById("form-delete-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
this._deleteNotify.checked = false;
this._deleteReason.value = "";
this._deleteReason.classList.add("unfocused");
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
"users": list,
"notify": this._deleteNotify.checked,
"reason": this._deleteNotify ? this._deleteReason.value : ""
};
_delete("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.deleteUser.close();
if (req.status != 200 && req.status != 204) {
let errorMsg = window.lang.notif("errorFailureCheckLogs");
if (!("error" in req.response)) {
errorMsg = window.lang.notif("errorPartialFailureCheckLogs");
}
window.notifications.customError("deleteUserError", errorMsg);
} else {
window.notifications.customSuccess("deleteUserSuccess", window.lang.quantity("deletedUser", this._checkCount));
}
this.reload();
}
});
};
window.modals.deleteUser.show();
}
modifyUsers = () => {
const modalHeader = document.getElementById("header-modify-user");
modalHeader.textContent = window.lang.quantity("modifySettingsFor", this._checkCount)
let list = this._collectUsers();
(() => {
let innerHTML = "";
for (const profile of window.availableProfiles) {
innerHTML += `<option value="${profile}">${profile}</option>`;
}
this._profileSelect.innerHTML = innerHTML;
})();
(() => {
let innerHTML = "";
for (let id in this._users) {
innerHTML += `<option value="${id}">${this._users[id].name}</option>`;
}
this._userSelect.innerHTML = innerHTML;
})();
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) => {
event.preventDefault();
toggleLoader(button);
let send = {
"apply_to": list,
"homescreen": (document.getElementById("modify-user-homescreen") as HTMLInputElement).checked
};
if (this._modifySettingsProfile.checked && !this._modifySettingsUser.checked) {
send["from"] = "profile";
send["profile"] = this._profileSelect.value;
} else if (this._modifySettingsUser.checked && !this._modifySettingsProfile.checked) {
send["from"] = "user";
send["id"] = this._userSelect.value;
}
_post("/users/settings", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 500) {
let response = JSON.parse(req.response);
let errorMsg = "";
if ("homescreen" in response && "policy" in response) {
const homescreen = Object.keys(response["homescreen"]).length;
const policy = Object.keys(response["policy"]).length;
if (homescreen != 0 && policy == 0) {
errorMsg = window.lang.notif("errorSettingsAppliedNoHomescreenLayout");
} else if (policy != 0 && homescreen == 0) {
errorMsg = window.lang.notif("errorHomescreenAppliedNoSettings");
} else if (policy != 0 && homescreen != 0) {
errorMsg = window.lang.notif("errorSettingsFailed");
}
} else if ("error" in response) {
errorMsg = response["error"];
}
window.notifications.customError("modifySettingsError", errorMsg);
} else if (req.status == 200 || req.status == 204) {
window.notifications.customSuccess("modifySettingsSuccess", window.lang.quantity("appliedSettings", this._checkCount));
}
this.reload();
window.modals.modifyUser.close();
}
});
};
window.modals.modifyUser.show();
}
constructor() {
this._users = {};
this._selectAll.checked = false;
this._selectAll.onchange = () => { this.selectAll = this._selectAll.checked };
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
this._addUserButton.onclick = window.modals.addUser.toggle;
this._addUserForm.addEventListener("submit", this._addUser);
this._deleteNotify.onchange = () => {
if (this._deleteNotify.checked) {
this._deleteReason.classList.remove("unfocused");
} else {
this._deleteReason.classList.add("unfocused");
}
};
this._modifySettings.onclick = this.modifyUsers;
this._modifySettings.classList.add("unfocused");
const checkSource = () => {
const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
if (this._modifySettingsProfile.checked) {
this._userSelect.parentElement.classList.add("unfocused");
this._profileSelect.parentElement.classList.remove("unfocused")
profileSpan.classList.add("!high");
profileSpan.classList.remove("!normal");
userSpan.classList.remove("!high");
userSpan.classList.add("!normal");
} else {
this._userSelect.parentElement.classList.remove("unfocused");
this._profileSelect.parentElement.classList.add("unfocused");
userSpan.classList.add("!high");
userSpan.classList.remove("!normal");
profileSpan.classList.remove("!high");
profileSpan.classList.add("!normal");
}
};
this._modifySettingsProfile.onchange = checkSource;
this._modifySettingsUser.onchange = checkSource;
this._deleteUser.onclick = this.deleteUsers;
this._deleteUser.classList.add("unfocused");
if (!window.usernameEnabled) {
this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail;
}
/*if (!window.emailEnabled) {
this._deleteNotify.parentElement.classList.add("unfocused");
this._deleteNotify.checked = false;
}*/
}
reload = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
let accountsOnDOM: { [id: string]: boolean } = {};
for (let id in this._users) { accountsOnDOM[id] = true; }
for (let u of (req.response["users"] as User[])) {
if (u.id in this._users) {
this._users[u.id].update(u);
delete accountsOnDOM[u.id];
} else {
this.add(u);
}
}
for (let id in accountsOnDOM) {
this._users[id].remove();
delete this._users[id];
}
this._checkCheckCount;
}
})
}

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