1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-09-19 10:50:11 +00:00
This commit is contained in:
Harvey Tindall 2020-12-12 22:15:43 +00:00
parent c79f86137e
commit 6efb7c4ecb
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
104 changed files with 1371 additions and 12766 deletions

View File

@ -1,20 +0,0 @@
FROM golang:latest AS build
COPY . /opt/build
RUN 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 \
&& (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
FROM golang:latest
COPY --from=build /opt/build/build /opt/jfa-go
EXPOSE 8056
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Harvey Tindall
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,57 +0,0 @@
configuration:
$(info Fixing config-base)
python3 config/fixconfig.py -i config/config-base.json -o 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
email:
$(info Generating email html)
python3 mail/generate.py
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
ts-debug:
-npx tsc -p ts/ --sourceMap
-rm -r data/static/ts
-rm -r data/static/typings
cp -r ts data/static/
swagger:
go get github.com/swaggo/swag/cmd/swag
swag init -g main.go
version:
python3 version.py auto version.go
compile:
$(info Downloading deps)
go mod download
$(info Building)
mkdir -p build
CGO_ENABLED=0 go build -o build/jfa-go *.go
compress:
upx --lzma build/jfa-go
copy:
$(info Copying data)
cp -r data build/
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

View File

@ -1,89 +1,11 @@
# ![jfa-go](data/static/banner.svg)
This branch is for experimenting with [a17t](https://a17t.miles.land/) to possibly replace bootstrap in the future. Currently just working on the page structures, so none of this is actually usable in jfa-go yet.
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.
#### currently done:
- [x] invites tab mockup (partially complete)
- [ ] accounts tab mockup
- [ ] settings tab mockup
- [ ] modals (may not use them at all, who knows)
- [ ] animations
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.
#### Features
* 🧑 Invite based account creation: Sends invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When user's forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email.
* Notifications: Get notified when someone creates an account, or an invite expires.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizable look
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* Optionally provide custom CSS
## Interface
<p align="center">
<img src="https://github.com/hrfee/jfa-go/blob/main/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>
</p>
#### Install
Available on the AUR as [jfa-go](https://aur.archlinux.org/packages/jfa-go/) or [jfa-go-git](https://aur.archlinux.org/packages/jfa-go-git/).
For other platforms, grab an archive from the release section for your platform (or nightly builds [here](https://builds.hrfee.pw/view/hrfee/jfa-go)), and extract `jfa-go` and `data` to the same directory.
* For linux users, you can place them inside `/opt/jfa-go` and then run
`sudo ln -s /opt/jfa-go/jfa-go /usr/bin/jfa-go` to place it in your PATH.
Run the executable to start.
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 \
-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
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
```
#### Build from source
If you're using docker, a Dockerfile is provided that builds from source.
Otherwise, full build instructions can be found [here](https://github.com/hrfee/jfa-go/wiki/Build).
#### Usage
Simply run `jfa-go` to start the application. A setup wizard will start on `localhost:8056` (or your own specified address). Upon completion, refresh the page.
Note: jfa-go does not run as a daemon by default. You'll need to figure this out yourself.
```
Usage of ./jfa-go:
-config string
alternate path to config file. (default "~/.config/jfa-go/config.ini")
-data string
alternate path to data directory. (default "~/.config/jfa-go")
-debug
Enables debug logging and exposes pprof.
-host string
alternate address to host web ui on.
-port int
alternate port to host web ui on.
-swagger
Enable swagger at /swagger/index.html
```
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### screenshots
![invites](images/invites.png)

1264
api.go

File diff suppressed because it is too large Load Diff

228
auth.go
View File

@ -1,228 +0,0 @@
package main
import (
"encoding/base64"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
)
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string) (string, string, error) {
var token, refresh string
claims := jwt.MapClaims{
"valid": true,
"id": userId,
"exp": strconv.FormatInt(time.Now().Add(time.Minute*20).Unix(), 10),
"jfid": jfId,
"type": "bearer",
}
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
return "", "", err
}
claims["exp"] = strconv.FormatInt(time.Now().Add(time.Hour*24).Unix(), 10)
claims["type"] = "refresh"
tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
return "", "", err
}
return token, refresh, nil
}
// Check header for token
func (app *appContext) authenticate(gc *gin.Context) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header")
respond(401, "Unauthorized", gc)
return
}
token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64)
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.debug.Printf("Auth denied: Invalid token")
respond(401, "Unauthorized", gc)
return
}
userID := claims["id"].(string)
jfID := claims["jfid"].(string)
match := false
for _, user := range app.users {
if user.UserID == userID {
match = true
break
}
}
if !match {
app.debug.Printf("Couldn't find user ID \"%s\"", userID)
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfID)
gc.Set("userId", userID)
app.debug.Println("Auth succeeded")
gc.Next()
}
func checkToken(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
}
return []byte(os.Getenv("JFA_SECRET")), nil
}
type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
}
// @Summary Grabs an API token using username & password.
// @description Click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`".
// @Produce json
// @Success 200 {object} getTokenDTO
// @Failure 401 {object} stringResponse
// @Router /token/login [get]
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
var userID, jfID string
if creds[0] == "" || creds[1] == "" {
app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return
}
match := false
for _, user := range app.users {
if user.Username == creds[0] && user.Password == creds[1] {
match = true
app.debug.Println("Found existing user")
userID = user.UserID
break
}
}
if !app.jellyfinLogin && !match {
app.info.Println("Auth denied: Invalid username/password")
respond(401, "Unauthorized", gc)
return
}
if !match {
var status int
var err error
var user map[string]interface{}
user, status, err = app.authJf.Authenticate(creds[0], creds[1])
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
respond(500, "Jellyfin error", gc)
return
}
jfID = user["Id"].(string)
if app.config.Section("ui").Key("admin_only").MustBool(true) {
if !user["Policy"].(map[string]interface{})["IsAdministrator"].(bool) {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0])
respond(401, "Unauthorized", gc)
return
}
}
// New users are only added when using jellyfinLogin.
userID = shortuuid.New()
newUser := User{
UserID: userID,
}
app.debug.Printf("Token generated for user \"%s\"", creds[0])
app.users = append(app.users, newUser)
}
token, refresh, err := CreateToken(userID, jfID)
if err != nil {
app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
gc.JSON(200, getTokenDTO{token})
}
// @Summary Grabs an API token using a refresh token from cookies.
// @Produce json
// @Success 200 {object} getTokenDTO
// @Failure 401 {object} stringResponse
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
cookie, err := gc.Cookie("refresh")
if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
respond(400, "Couldn't get token", gc)
return
}
for _, token := range app.invalidTokens {
if cookie == token {
app.debug.Println("getTokenRefresh: Invalid token")
respond(401, "Invalid token", gc)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.debug.Println("getTokenRefresh: Invalid token")
respond(400, "Invalid token", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64)
if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %s", err)
respond(401, "Invalid token", gc)
return
}
userID := claims["id"].(string)
jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID)
if err != nil {
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@ -1,23 +0,0 @@
package common
import (
"fmt"
"log"
)
// TimeoutHandler recovers from an http timeout.
type TimeoutHandler func()
// NewTimeoutHandler returns a new Timeout handler.
func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
return func() {
if r := recover(); r != nil {
out := fmt.Sprintf("Failed to authenticate with %s @ %s: Timed out", name, addr)
if noFail {
log.Print(out)
} else {
log.Fatalf(out)
}
}
}
}

View File

@ -1,3 +0,0 @@
module github.com/hrfee/jfa-go/common
go 1.15

View File

@ -1,86 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
/*var DeCamel ini.NameMapper = func(raw string) string {
out := make([]rune, 0, len(raw))
upper := 0
for _, c := range raw {
if unicode.IsUpper(c) {
upper++
}
if upper == 2 {
out = append(out, '_')
upper = 0
}
out = append(out, unicode.ToLower(c))
}
return string(out)
}
func (app *appContext) loadDefaults() (err error) {
var cfb []byte
cfb, err = ioutil.ReadFile(app.configBase_path)
if err != nil {
return
}
json.Unmarshal(cfb, app.defaults)
return
}*/
func (app *appContext) loadConfig() error {
var err error
app.config, err = ini.Load(app.configPath)
if err != nil {
return err
}
app.config.Section("jellyfin").Key("public_server").SetValue(app.config.Section("jellyfin").Key("public_server").MustString(app.config.Section("jellyfin").Key("server").String()))
for _, key := range app.config.Section("files").Keys() {
// if key.MustString("") == "" && key.Name() != "custom_css" {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
if key.Name() != "html_templates" {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_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.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.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.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.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.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.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))
app.email = NewEmailer(app)
return nil
}

View File

@ -1,11 +0,0 @@
### fixconfig
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so this script opens the json file, and for each section, adds an "order" list which tells the web page in which order to display settings.
Specify the input and output files with `-i` and `-o` respectively.
### jsontostruct
Generates a go struct from `config-base.json`. I wrote this because i was annoyed with the `ini` library, but i've since realised mapping the ini values onto it is painful.

View File

@ -1,668 +0,0 @@
{
"jellyfin": {
"meta": {
"name": "Jellyfin",
"description": "Settings for connecting to Jellyfin"
},
"username": {
"name": "Jellyfin Username",
"required": true,
"requires_restart": true,
"type": "text",
"value": "username",
"description": "It is recommended to create a limited admin account for this program."
},
"password": {
"name": "Jellyfin Password",
"required": true,
"requires_restart": true,
"type": "password",
"value": "password"
},
"server": {
"name": "Server address",
"required": true,
"requires_restart": true,
"type": "text",
"value": "http://jellyfin.local:8096",
"description": "Jellyfin server address. Can be public, or local for security purposes."
},
"public_server": {
"name": "Public address",
"required": false,
"requires_restart": false,
"type": "text",
"value": "https://jellyf.in:443",
"description": "Publicly accessible Jellyfin address for invite form. Leave blank to reuse the above address."
},
"client": {
"name": "Client Name",
"required": true,
"requires_restart": true,
"type": "text",
"value": "jfa-go",
"description": "The name of the client that will show up in the Jellyfin dashboard."
},
"cache_timeout": {
"name": "User cache timeout (minutes)",
"required": false,
"requires_restart": true,
"type": "number",
"value": 30,
"description": "Timeout of user cache in minutes. Set to 0 to disable."
}
},
"ui": {
"meta": {
"name": "General",
"description": "Settings related to the UI and program functionality."
},
"language": {
"name": "Language",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
"en-us"
],
"value": "en-US",
"description": "UI Language. Currently only implemented for account creation form. Submit a PR on github if you'd like to translate."
},
"theme": {
"name": "Default Look",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
"Bootstrap (Light)",
"Jellyfin (Dark)",
"Custom CSS"
],
"value": "Jellyfin (Dark)",
"description": "Default appearance for all users."
},
"host": {
"name": "Address",
"required": true,
"requires_restart": true,
"type": "text",
"value": "0.0.0.0",
"description": "Set 0.0.0.0 to run on localhost"
},
"port": {
"name": "Port",
"required": true,
"requires_restart": true,
"type": "number",
"value": 8056
},
"jellyfin_login": {
"name": "Use Jellyfin for authentication",
"required": false,
"requires_restart": true,
"type": "bool",
"value": true,
"description": "Enable this to use Jellyfin users instead of the below username and pw."
},
"admin_only": {
"name": "Allow admin users only",
"required": false,
"requires_restart": true,
"depends_true": "jellyfin_login",
"type": "bool",
"value": true,
"description": "Allows only admin users on Jellyfin to access the admin page."
},
"username": {
"name": "Web Username",
"required": true,
"requires_restart": true,
"depends_false": "jellyfin_login",
"type": "text",
"value": "your username",
"description": "Username for admin page (Leave blank if using jellyfin_login)"
},
"password": {
"name": "Web Password",
"required": true,
"requires_restart": true,
"depends_false": "jellyfin_login",
"type": "password",
"value": "your password",
"description": "Password for admin page (Leave blank if using jellyfin_login)"
},
"email": {
"name": "Admin email address",
"required": false,
"requires_restart": false,
"depends_false": "jellyfin_login",
"type": "text",
"value": "example@example.com",
"description": "Address to send notifications to (Leave blank if using jellyfin_login)"
},
"debug": {
"name": "Debug logging",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enables debug logging and exposes pprof as a route (Don't use in production!)"
},
"contact_message": {
"name": "Contact message",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Need help? contact me.",
"description": "Displayed at bottom of all pages except admin"
},
"help_message": {
"name": "Help message",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Enter your details to create an account.",
"description": "Displayed at top of invite form."
},
"success_message": {
"name": "Success message",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Your account has been created. Click below to continue to Jellyfin.",
"description": "Displayed when a user creates an account"
},
"bs5": {
"name": "Use Bootstrap 5",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Use the Bootstrap 5 Alpha. Looks better and removes the need for jQuery, so the page should load faster."
},
"url_base": {
"name": "URL Base",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "URL base for when running jfa-go with a reverse proxy in a subfolder."
}
},
"password_validation": {
"meta": {
"name": "Password Validation",
"description": "Password validation (minimum length, etc.)"
},
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": false,
"type": "bool",
"value": true
},
"min_length": {
"name": "Minimum Length",
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "8"
},
"upper": {
"name": "Minimum uppercase characters",
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "1"
},
"lower": {
"name": "Minimum lowercase characters",
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "0"
},
"number": {
"name": "Minimum number count",
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "1"
},
"special": {
"name": "Minimum number of special characters",
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "0"
}
},
"email": {
"meta": {
"name": "Email",
"description": "General email settings. Ignore if not using email features."
},
"no_username": {
"name": "Use email addresses as username",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "bool",
"value": false,
"description": "Use email address from invite form as username on Jellyfin."
},
"use_24h": {
"name": "Use 24h time",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "bool",
"value": true
},
"date_format": {
"name": "Date format",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "text",
"value": "%d/%m/%y",
"description": "Date format used in emails. Follows datetime.strftime format."
},
"message": {
"name": "Help message",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "text",
"value": "Need help? contact me.",
"description": "Message displayed at bottom of emails."
},
"method": {
"name": "Email method",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
"smtp",
"mailgun"
],
"value": "smtp",
"description": "Method of sending email to use."
},
"address": {
"name": "Sent from (address)",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "email",
"value": "jellyfin@jellyf.in",
"description": "Address to send emails from"
},
"from": {
"name": "Sent from (name)",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "text",
"value": "Jellyfin",
"description": "The name of the sender"
}
},
"password_resets": {
"meta": {
"name": "Password Resets",
"description": "Settings for the password reset handler."
},
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": true,
"description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins"
},
"watch_directory": {
"name": "Jellyfin directory",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "/path/to/jellyfin",
"description": "Path to the folder Jellyfin puts password-reset files."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "Password Reset - Jellyfin",
"description": "Subject of password reset emails."
}
},
"invite_emails": {
"meta": {
"name": "Invite emails",
"description": "Settings for sending invites directly to users."
},
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": false,
"type": "bool",
"value": true
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to custom email HTML"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
},
"subject": {
"name": "Email subject",
"required": true,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "Invite - Jellyfin",
"description": "Subject of invite emails."
},
"url_base": {
"name": "URL Base",
"required": true,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "http://accounts.jellyf.in:8056/invite",
"description": "Base URL for jfa-go. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself."
}
},
"notifications": {
"meta": {
"name": "Notifications",
"description": "Notification related settings."
},
"enabled": {
"name": "Enabled",
"required": "false",
"requires_restart": true,
"type": "bool",
"value": true,
"description": "Enabling adds optional toggles to invites to notify on expiry and user creation."
},
"expiry_html": {
"name": "Expiry email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to expiry notification email HTML."
},
"expiry_text": {
"name": "Expiry email (Plaintext)",
"required": false,
"requires_restart": "false",
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to expiry notification email in plaintext."
},
"created_html": {
"name": "User created email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to user creation notification email HTML."
},
"created_text": {
"name": "User created email (Plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to user creation notification email in plaintext."
}
},
"mailgun": {
"meta": {
"name": "Mailgun (Email)",
"description": "Mailgun API connection settings"
},
"api_url": {
"name": "API URL",
"required": false,
"requires_restart": false,
"type": "text",
"value": "https://api.mailgun.net..."
},
"api_key": {
"name": "API Key",
"required": false,
"requires_restart": false,
"type": "text",
"value": "your api key"
}
},
"smtp": {
"meta": {
"name": "SMTP (Email)",
"description": "SMTP Server connection settings."
},
"username": {
"name": "Username",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Username for SMTP. Leave blank to user send from address as username."
},
"encryption": {
"name": "Encryption Method",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
"ssl_tls",
"starttls"
],
"value": "starttls",
"description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls."
},
"server": {
"name": "Server address",
"required": false,
"requires_restart": false,
"type": "text",
"value": "smtp.jellyf.in",
"description": "SMTP Server address."
},
"port": {
"name": "Port",
"required": false,
"requires_restart": false,
"type": "number",
"value": 465
},
"password": {
"name": "Password",
"required": false,
"requires_restart": false,
"type": "password",
"value": "smtp password"
}
},
"ombi": {
"meta": {
"name": "Ombi Integration",
"description": "Connect to Ombi to automatically create both Ombi and Jellyfin accounts for new users. You'll need to create a user template for this to work. Once enabled, refresh to see an option in settings for this."
},
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable to create an Ombi account for new Jellyfin users"
},
"server": {
"name": "URL",
"required": false,
"requires_restart": true,
"type": "text",
"value": "localhost:5000",
"depends_true": "enabled",
"description": "Ombi server URL, including http(s)://."
},
"api_key": {
"name": "API Key",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"depends_true": "enabled",
"description": "API Key. Get this from the first tab in Ombi settings."
}
},
"deletion": {
"meta": {
"name": "Account Deletion",
"description": "Subject/email files for account deletion emails."
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Your account was deleted - Jellyfin",
"description": "Subject of account deletion emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
},
"files": {
"meta": {
"name": "File Storage",
"description": "Optional settings for changing storage locations."
},
"invites": {
"name": "Invite Storage",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of stored invites (json)."
},
"emails": {
"name": "Email Addresses",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of stored email addresses (json)."
},
"ombi_template": {
"name": "Ombi user template",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Location of stored Ombi user template."
},
"user_template": {
"name": "User Template (Deprecated)",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Deprecated in favor of User Profiles. Location of stored user policy template (json)."
},
"user_configuration": {
"name": "userConfiguration (Deprecated)",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Deprecated in favor of User Profiles. Location of stored user configuration template (used for setting homescreen layout) (json)"
},
"user_displayprefs": {
"name": "displayPreferences (Deprecated)",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Deprecated in favor of User Profiles. Location of stored displayPreferences template (also used for homescreen layout) (json)"
},
"user_profiles": {
"name": "User Profiles",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of stored user profiles (encompasses template and configuration and displayprefs) (json)"
},
"custom_css": {
"name": "Custom CSS",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of custom bootstrap CSS."
},
"html_templates": {
"name": "Custom HTML Template Directory",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Path to directory containing custom versions of web ui pages. See wiki for more info."
}
}
}

View File

@ -1,541 +0,0 @@
package main
type Metadata struct{
Name string `json:"name"`
Description string `json:"description"`
}
type Config struct{
Order []string `json:"order"`
Jellyfin struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Username struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"username"`
} `json:"username" cfg:"username"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
Server struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"server"`
} `json:"server" cfg:"server"`
PublicServer struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"public_server"`
} `json:"public_server" cfg:"public_server"`
Client struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"client"`
} `json:"client" cfg:"client"`
Version struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"version"`
} `json:"version" cfg:"version"`
Device struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"device"`
} `json:"device" cfg:"device"`
DeviceId struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"device_id"`
} `json:"device_id" cfg:"device_id"`
} `json:"jellyfin"`
Ui struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Theme struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"theme"`
} `json:"theme" cfg:"theme"`
Host struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"host"`
} `json:"host" cfg:"host"`
Port struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value int `json:"value" cfg:"port"`
} `json:"port" cfg:"port"`
JellyfinLogin struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"jellyfin_login"`
} `json:"jellyfin_login" cfg:"jellyfin_login"`
AdminOnly struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"admin_only"`
} `json:"admin_only" cfg:"admin_only"`
Username struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"username"`
} `json:"username" cfg:"username"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
Email struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email"`
} `json:"email" cfg:"email"`
Debug struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"debug"`
} `json:"debug" cfg:"debug"`
ContactMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"contact_message"`
} `json:"contact_message" cfg:"contact_message"`
HelpMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"help_message"`
} `json:"help_message" cfg:"help_message"`
SuccessMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"success_message"`
} `json:"success_message" cfg:"success_message"`
Bs5 struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"bs5"`
} `json:"bs5" cfg:"bs5"`
} `json:"ui"`
PasswordValidation struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
MinLength struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"min_length"`
} `json:"min_length" cfg:"min_length"`
Upper struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"upper"`
} `json:"upper" cfg:"upper"`
Lower struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"lower"`
} `json:"lower" cfg:"lower"`
Number struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"number"`
} `json:"number" cfg:"number"`
Special struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"special"`
} `json:"special" cfg:"special"`
} `json:"password_validation"`
Email struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
NoUsername struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"no_username"`
} `json:"no_username" cfg:"no_username"`
Use24H struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"use_24h"`
} `json:"use_24h" cfg:"use_24h"`
DateFormat struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"date_format"`
} `json:"date_format" cfg:"date_format"`
Message struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"message"`
} `json:"message" cfg:"message"`
Method struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"method"`
} `json:"method" cfg:"method"`
Address struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"address"`
} `json:"address" cfg:"address"`
From struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"from"`
} `json:"from" cfg:"from"`
} `json:"email"`
PasswordResets struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
WatchDirectory struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"watch_directory"`
} `json:"watch_directory" cfg:"watch_directory"`
EmailHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_html"`
} `json:"email_html" cfg:"email_html"`
EmailText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_text"`
} `json:"email_text" cfg:"email_text"`
Subject struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"subject"`
} `json:"subject" cfg:"subject"`
} `json:"password_resets"`
InviteEmails struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
EmailHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_html"`
} `json:"email_html" cfg:"email_html"`
EmailText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_text"`
} `json:"email_text" cfg:"email_text"`
Subject struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"subject"`
} `json:"subject" cfg:"subject"`
UrlBase struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"url_base"`
} `json:"url_base" cfg:"url_base"`
} `json:"invite_emails"`
Notifications struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
ExpiryHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"expiry_html"`
} `json:"expiry_html" cfg:"expiry_html"`
ExpiryText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"expiry_text"`
} `json:"expiry_text" cfg:"expiry_text"`
CreatedHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"created_html"`
} `json:"created_html" cfg:"created_html"`
CreatedText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"created_text"`
} `json:"created_text" cfg:"created_text"`
} `json:"notifications"`
Mailgun struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
ApiUrl struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"api_url"`
} `json:"api_url" cfg:"api_url"`
ApiKey struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"api_key"`
} `json:"api_key" cfg:"api_key"`
} `json:"mailgun"`
Smtp struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Encryption struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"encryption"`
} `json:"encryption" cfg:"encryption"`
Server struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"server"`
} `json:"server" cfg:"server"`
Port struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value int `json:"value" cfg:"port"`
} `json:"port" cfg:"port"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
} `json:"smtp"`
Files struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Invites struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"invites"`
} `json:"invites" cfg:"invites"`
Emails struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"emails"`
} `json:"emails" cfg:"emails"`
UserTemplate struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_template"`
} `json:"user_template" cfg:"user_template"`
UserConfiguration struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_configuration"`
} `json:"user_configuration" cfg:"user_configuration"`
UserDisplayprefs struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_displayprefs"`
} `json:"user_displayprefs" cfg:"user_displayprefs"`
CustomCss struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"custom_css"`
} `json:"custom_css" cfg:"custom_css"`
} `json:"files"`
}

View File

@ -1,27 +0,0 @@
import json, argparse
parser = argparse.ArgumentParser()
parser.add_argument("-i", "--input", help="input config base from jf-accounts")
parser.add_argument("-o", "--output", help="output config base for jfa-go")
args = parser.parse_args()
with open(args.input, 'r') as f:
config = json.load(f)
newconfig = {"order": []}
for sect in config:
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]
with open(args.output, 'w') as f:
f.write(json.dumps(newconfig, indent=4))

View File

@ -1,42 +0,0 @@
# Generates config file
import configparser
import json
import argparse
from pathlib import Path
def generate_ini(base_file, ini_file):
"""
Generates .ini file from config-base file.
"""
with open(Path(base_file), "r") as f:
config_base = json.load(f)
ini = configparser.RawConfigParser(allow_no_value=True)
for section in config_base:
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)
with open(Path(ini_file), "w") as config_file:
ini.write(config_file)
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-i", "--input", help="input config base from jf-accounts")
parser.add_argument("-o", "--output", help="output ini")
args = parser.parse_args()
print(generate_ini(base_file=args.input, ini_file=args.output))

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("}")

View File

@ -1,50 +0,0 @@
package main
import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type repeater struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
app *appContext
}
func newRepeater(interval time.Duration, app *appContext) *repeater {
return &repeater{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
}
func (rt *repeater) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
rt.app.storage.loadInvites()
rt.app.debug.Println("Daemon: Checking invites")
rt.app.checkInvites()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *repeater) shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
}

View File

@ -1,41 +0,0 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"pageTitle": "Create Jellyfin Account",
"createAccountHeader": "Create Account",
"accountDetails": "Details",
"emailAddress": "Email",
"username": "Username",
"password": "Password",
"reEnterPassword": "Re-enter Password",
"reEnterPasswordInvalid": "Passwords are not the same.",
"createAccountButton": "Create Account",
"passwordRequirementsHeader": "Password Requirements",
"successHeader": "Success!",
"successContinueButton": "Continue",
"validationStrings": {
"length": {
"singular": "Must have at least {n} character",
"plural": "Must have a least {n} characters"
},
"uppercase": {
"singular": "Must have at least {n} uppercase character",
"plural": "Must have at least {n} uppercase characters"
},
"lowercase": {
"singular": "Must have at least {n} lowercase character",
"plural": "Must have at least {n} lowercase characters"
},
"number": {
"singular": "Must have at least {n} number",
"plural": "Must have at least {n} numbers"
},
"special": {
"singular": "Must have at least {n} special character",
"plural": "Must have at least {n} special characters"
}
}
}
}

View File

@ -1,42 +0,0 @@
{
"meta": {
"name": "Francais (FR)",
"author": "https://github.com/Killianbe"
},
"strings": {
"pageTitle": "Créer un compte Jellyfin",
"createAccountHeader": "Création du compte",
"accountDetails": "Détails",
"emailAddress": "Email",
"username": "Pseudo",
"password": "Mot de passe",
"reEnterPassword": "Confirmez mot de passe",
"reEnterPasswordInvalid": "Les mots de passe ne correspondent pas.",
"createAccountButton": "Créer le compte",
"passwordRequirementsHeader": "Mot de passe requis",
"successHeader": "Succes!",
"successContinueButton": "Continuer",
"validationStrings": {
"length": {
"singular": "Doit avoir au moins {n} caractère",
"plural": "Doit avoir au moins {n} caractères"
},
"uppercase": {
"singular": "Doit avoir au moins {n} caractère majuscule",
"plural": "Must have at least {n} caractères majuscules"
},
"lowercase": {
"singular": "Doit avoir au moins {n} caractère minuscule",
"plural": "Doit avoir au moins {n} caractères minuscules"
},
"number": {
"singular": "Doit avoir au moins {n} nombre",
"plural": "Doit avoir au moins {n} nombres"
},
"special": {
"singular": "Doit avoir au moins {n} caractère spécial",
"plural": "Doit avoir au moins {n} caractères spéciaux"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#603cba</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -1 +0,0 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="682.667" height="682.667" viewBox="0 0 512.000000 512.000000"><path d="M246 23.7c-13.9 2-30 8.6-40.6 16.6-20.2 15.4-32.5 40-32.5 65.5 0 38.8 24.2 70.7 61.9 81.4 11.1 3.2 31.4 3.1 42.7-.1 47.8-13.4 73-62.2 56.5-109.5-7.8-22.5-27.9-42.1-51.5-50-10.6-3.6-26.4-5.3-36.5-3.9zM235.5 205.6c-37.7 6.8-73.7 27.7-106.2 61.8-31.6 33.1-54.2 73.1-62.4 110.8-3 13.3-3.2 35.7-.5 45.8 5.6 21.2 20.1 36.8 43.4 46.9 9.2 3.9 21.4 7.8 30.7 9.6 10 1.9 26.1 4.5 32.5 5.1 4.1.4 8.9.8 10.5 1 25.3 2.5 85.7 2.4 107-.1 1.7-.2 5.7-.7 9-1 6.8-.7 11.2-1.2 17-1.9 34.7-4.4 70.3-13.2 90.8-22.4 17.8-8.1 30.7-18.6 35.4-28.8 5.8-12.6 6.6-36.1 1.9-56.4-5-22-16.8-48.3-31.5-70.5-33.6-50.8-80.8-87-128.1-98.2-13.3-3.1-37.1-3.9-49.5-1.7z"/></svg>

Before

Width:  |  Height:  |  Size: 768 B

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,19 +0,0 @@
{
"name": "jf-accounts",
"short_name": "jf-accounts",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>404 - jfa-go</title>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ .contactMessage }}
</p>
</div>
</body>
</html>

View File

@ -1,473 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<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 "";
}
window.URLBase = "{{ .urlBase }}";
{{ 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>
{{ template "header.html" . }}
<title>Admin - jfa-go</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,10 +0,0 @@
{{ define "form-base" }}
<script>
window.bs5 = {{ .bs5 }};
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .lang.validationStrings }});
window.invalidPassword = "{{ .lang.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
</script>
<script src="form.js" type="module"></script>
{{ end }}

View File

@ -1 +0,0 @@
{{ template "form.html" . }}

View File

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<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 .username }}inputEmail{{ else }}inputUsername{{ end }}" name="{{ if .username }}email{{ else }}username{{ end }}" placeholder="{{ .lang.emailAddress }}" value="{{ .email }}" required>
</div>
{{ if .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,23 +0,0 @@
<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">
{{ 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.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
{{ if .bs5 }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.min.js" integrity="sha384-t6I8D5dJmMXjCsRLhSzCltuhNZg6P10kE0m0nAncLUjH6GeYLhRU1zfLoW3QNQDF" 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">

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Invalid Code - jfa-go</title>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<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

@ -1,374 +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="stylesheet" href="bs5-jf.css">
<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.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%;
height: 100%;
padding: 10%;
}
.slider {
width: 100%;
height: 100%;
display: flex;
overflow-x: hidden;
-webkit-overflow-scrolling: none;
scroll-snap-type: x mandatory;
}
.slider > div {
scroll-snap-align: start;
}
.slide {
width: 100%;
flex-shrink: 0;
height: 100%;
margin: 10%;
}
</style>
<title>Setup - jfa-go</title>
</head>
<body>
<div class="pageContainer">
<div class="container">
<div class="row">
<div class="col-sm"></div>
<div id="setupCarousel" class="col-md-auto slider">
<div class="slide card text-center" id="page-1">
<div class="card-body">
<h5 class="card-title">Welcome!</h5>
<p class="card-text">
You'll need to do a few things to start using jfa-go. Click below to get started, or quit and edit the config file manually.
</p>
<a class="btn btn-primary nextButton" href="#page-2">Get Started</a>
</div>
<div class="card-footer">
<small>Note: Make sure you are accessing this page through HTTPS, or on a private network.</small>
</div>
</div>
<div class="slide card" id="page-2">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p class="card-text">
To access the admin page, you'll need to login. Choose how below.
<ul>
<li><b>Authorize through Jellyfin: </b>Checks credentials with Jellyfin, allowing you to share login details and grant multiple users access.</li>
<li><b>Username & Password: </b>Set your own username and password manually.</li>
</ul>
<div class="form-check" id="jfAuthFormGroup">
<input class="form-check-input" type="radio" name="auth" id="jfAuthRadio" value="jfAuth" checked>
<label class="form-check-label" for="jfAuthRadio">
Authorize through Jellyfin
</label>
</div>
<div id="adminOnlyArea">
<div class="form-check" style="margin-left: 1rem;">
<input type="checkbox" class="form-check-input" id="jfAuthAdminOnly" checked>
<label for="jfAuthAdminOnly" class="form-check-label">Allow admin users only</label>
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="auth" id="manualAuthRadio" value="manualAuth">
<label class="form-check-label" for="manualAuthRadio">
Manual username &amp; password
</label>
</div>
<div id="manualAuthArea">
<div class="form-group">
<label for="manualAuthUsername">Username</label>
<input type="text" class="form-control" id="manualAuthUsername" placeholder="Username">
</div>
<div class="form-group">
<label for="manualAuthPassword">Password</label>
<input type="password" class="form-control" id="manualAuthPassword" placeholder="Password">
</div>
<div class="form-group">
<label for="manualAuthEmail">Email (Optional)</label>
<input type="email" class="form-control" id="manualAuthEmail" placeholder="example@example.com">
<small class="form-text text-muted">Your email address is only required if you want to recieve activity notifications.</small>
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-1">Back</a>
<a class="btn btn-primary nextButton" href="#page-3">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-3">
<div class="card-body">
<h5 class="card-title">Jellyfin</h5>
<p class="card-text">
jfa-go needs admin access so that it can create users, as this is currently not permitted via API tokens.
You should create a separate account for it, checking 'Allow this user to manage the server'. You can disable everything else. Once done, enter the credentials here.
<div class="form-group">
<label for="jfHost">Host (For internal use)</label>
<input type="url" class="form-control" id="jfHost" placeholder="http://jellyf.in:443" required>
</div>
<div class="form-group">
<label for="jfPublicHost">Public Host (For access by users)</label>
<input type="url" class="form-control" id="jfPublicHost" placeholder="Leave blank to use the above address.">
</div>
<div class="form-group">
<label for="jfUser">Username</label>
<input type="text" class="form-control" id="jfUser" placeholder="Username" required>
</div>
<div class="form-group">
<label for="jfPassword">Password</label>
<input type="password" class="form-control" id="jfPassword" placeholder="Password" required>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-secondary" id="jfTestButton">Test</button>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-2">Back</a>
<a class="btn btn-primary nextButton disabled" id="jfNextButton" aria-disabled="true" href="#page-4">Next</a>
</div>
</div>
</div>
</div>
<div class="slide card" id="page-4">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">jfa-go is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through <a href="https://www.mailgun.com/">Mailgun's</a> API.
<div class="form-group">
<div class="form-check" id="emailDisabled">
<input class="form-check-input" type="radio" name="email" id="emailDisabledRadio" value="emailDisabled">
<label class="form-check-label" for="emailDisabledRadio">
Disabled
</label>
</div>
<div class="form-check" id="emailSMTP">
<input class="form-check-input" type="radio" name="email" id="emailSMTPRadio" value="emailSMTP" checked>
<label class="form-check-label" for="emailSMTPRadio">
SMTP
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="email" id="emailMailgunRadio" value="emailMailgun">
<label class="form-check-label" for="emailMailgunRadio">
Mailgun API
</label>
</div>
</div>
<div id="emailSMTPArea">
<div class="form-group form-check form-switch">
<input type="checkbox" class="form-check-input" id="emailSSL_TLS" checked>
<label for="emailSSL_TLS" class="form-check-label" id="emailSSL_TLSLabel">Use SSL/TLS</label>
<small class="form-text text-muted">Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587.</small>
</div>
<div class="form-group form-row">
<div class="col">
<input type="text" class="form-control" id="emailSMTPServer" placeholder="SMTP Server Address">
</div>
<div class="col">
<input type="number" class="form-control" id="emailSMTPPort" placeholder="Port">
</div>
</div>
<div class="form-group form-row">
<div class="col">
<input type="email" class="form-control" id="emailSMTPAddress" placeholder="jellyfin@jellyf.in">
</div>
<div class="col">
<input type="password" class="form-control" id="emailSMTPPassword" placeholder="Password">
</div>
</div>
</div>
<div id="emailMailgunArea">
<div class="form-group">
<input type="url" class="form-control" id="emailMailgunURL" placeholder="API URL">
</div>
<div class="form-group">
<input type="password" class="form-control" id="emailMailgunKey" placeholder="API Key">
</div>
<div class="form-group">
<input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in">
</div>
</div>
<div id="emailCommonArea">
<h5 class="card-title">Notifications</h5>
<p class="card-text">Enabling notifications will allow you to choose (per-invite) to recieve emails when an invite expires, or when a new user is created. If you chose to use Manual auth instead of Jellyfin auth previously, make sure you provided an email address.</p>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="notificationsEnabled">
<label for="notificationsEnabled" class="form-check-label">Enabled</label>
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-3">Back</a>
<a class="btn btn-primary nextButton" id="emailNextButton" href="#page-5">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-5">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">Just a few more things to get your emails looking great.
<div class="form-group">
<label for="emailSender">Sender: The name shown when a user receives an email.</label>
<input type="text" class="form-control" id="emailSender" value="Jellyfin">
</div>
<div class="form-group">
<label for="emailDateFormat">Date Format: Follows <a target="_blank" href="https://strftime.org/">strftime</a> format.</label>
<input type="text" class="form-control" id="emailDateFormat" value="%d/%m/%y">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email24hTimeRadio" value="email24hTime" checked>
<label class="form-check-label" for="email24hTimeRadio">24h time</label>
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email12hTimeRadio" value="email12hTime">
<label class="form-check-label" for="email12hTimeRadio">12h time</label>
</div>
<div class="form-group">
<label for="emailMessage">Message: Short message displayed at the bottom of emails.</label>
<input type="text" class="form-control" id="emailMessage" value="Need help? Contact me.">
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-4">Back</a>
<a class="btn btn-primary nextButton" href="#page-6">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-6">
<div class="card-body">
<h5 class="card-title">Password Resets</h5>
<p class="card-text">
When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jfa-go will then read this file, and send the PIN to the user's email. Try it now, and put the folder that it informs you it put the file in below. Also, if enter a custom email subject if you don't like the default one.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="pwrEnabled" value="enabled">
<label class="form-check-label" for="pwrEnabled">Enabled</label>
</div>
<div id="pwrArea">
<div class="form-group">
<label for="pwrJfPath">Path to Jellyfin</label>
<input type="text" class="form-control" id="pwrJfPath" placeholder="Folder">
</div>
<div class="form-group">
<label for="pwrSubject">Email Subject</label>
<input type="text" class="form-control" id="pwrSubject" value="Password Reset - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-5">Back</a>
<a class="btn btn-primary nextButton" href="#page-7">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-7">
<div class="card-body">
<h5 class="card-title">Invite Emails</h5>
<p class="card-text">
Allows you to send an invite code directly to a specified email address.
Since you'll most likely being running this behind a reverse proxy, the program has no way of knowing the address it will be accessed from. This is needed for sending emails with links. Write your URL Base with the protocol and append '/invite', e.g:
<ul>
<li>On the local network, you might use <a href="#">http://localhost:8056/invite</a></li>
<li>Exposed to the internet, you might use <a href="#">https://accounts.jellyf.in/invite</a></li>
</ul>
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="invEnabled" value="enabled" checked>
<label class="form-check-label" for="invEnabled">Enabled</label>
</div>
<div id="invArea">
<div class="form-group">
<label for="invURLBase">URL Base</label>
<input type="url" class="form-control" id="invURLBase" placeholder="https://accounts.jellyf.in/invite">
</div>
<div class="form-group">
<label for="invSubject">Subject</label>
<input type="text" class="form-control" id="invSubject" value="Invite - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-6">Back</a>
<a class="btn btn-primary nextButton" href="#page-8">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-8">
<div class="card-body">
<h5 class="card-title">Password Validation</h5>
<p class="card-text">
Enabling this will display a set of password requirements on the create account page, such as minimum length, uppercase characters, special characters, etc.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="valEnabled" value="enabled">
<label class="form-check-label" for="valEnabled">Enabled</label>
</div>
<div id="valArea">
<div class="form-group">
<label for="valLength">Minimum Length</label>
<input type="number" class="form-control" id="valLength" value="8">
</div>
<div class="form-group">
<label for="valUpper">Minimum number of uppercase characters</label>
<input type="number" class="form-control" id="valUpper" value="1">
</div>
<div class="form-group">
<label for="valLower">Minimum number of lowercase characters</label>
<input type="number" class="form-control" id="valLower" value="0">
</div>
<div class="form-group">
<label for="valNumber">Minimum number of numbers</label>
<input type="number" class="form-control" id="valNumber" value="0">
</div>
<div class="form-group">
<label for="valSpecial">Minimum number of special characters</label>
<input type="number" class="form-control" id="valSpecial" value="0">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" id="valBackButton" href="#page-7">Back</a>
<a class="btn btn-primary nextButton" href="#page-9">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-9">
<div class="card-body">
<h5 class="card-title">Help Messages</h5>
<p class="card-text">
Just a few little messages that will display in various places. Leave these alone if you want.
</p>
<div class="form-group">
<label for="msgContact">Contact message: Displays at bottom of all pages (except admin).</label>
<input id="msgContact" type="text" class="form-control" value="Need help? Contact me.">
</div>
<div class="form-group">
<label for="msgHelp">Help message: Displays when a user is creating an account.</label>
<input id="msgHelp" type="text" class="form-control" value="Enter your details to create an account.">
</div>
<div class="form-group">
<label for="msgSuccess">Success message: Displays when a user successfully creates an account, just above a button taking the user to Jellyfin.</label>
<input id="msgSuccess" type="text" class="form-control" value="Your account has been created. Click below to continue to Jellyfin.">
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-8">Back</a>
<a class="btn btn-primary nextButton" href="#page-10">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-10">
<div class="card-body text-center">
<h5 class="card-title">Finished!</h5>
<p class="card-text">
Press the button below to submit your settings. The program will restart. Once it's done, refresh this page.
</p>
<button id="submitButton" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
<div class="col-sm"></div>
</div>
</div>
</div>
<script src="setup.js"></script>
</body>
</html>

View File

@ -1,8 +0,0 @@
module github.com/hrfee/jfa-go/docs
go 1.15
require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/swaggo/swag v1.6.7
)

317
email.go
View File

@ -1,317 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"html/template"
"net/smtp"
"strings"
"time"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
)
// implements email sending, right now via smtp or mailgun.
type emailClient interface {
send(address, fromName, fromAddr string, email *Email) error
}
// Mailgun client implements emailClient.
type Mailgun struct {
client *mailgun.MailgunImpl
}
func (mg *Mailgun) send(address, fromName, fromAddr string, email *Email) error {
message := mg.client.NewMessage(
fmt.Sprintf("%s <%s>", fromName, fromAddr),
email.subject,
email.text,
address,
)
message.SetHtml(email.html)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, _, err := mg.client.Send(ctx, message)
return err
}
// SMTP supports SSL/TLS and STARTTLS; implements emailClient.
type SMTP struct {
sslTLS bool
server string
port int
auth smtp.Auth
}
func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error {
e := jEmail.NewEmail()
e.Subject = email.subject
e.From = fmt.Sprintf("%s <%s>", fromName, fromAddr)
e.To = []string{address}
e.Text = []byte(email.text)
e.HTML = []byte(email.html)
server := fmt.Sprintf("%s:%d", sm.server, sm.port)
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: sm.server,
}
var err error
fmt.Println(server)
// err = e.Send(server, sm.auth)
if sm.sslTLS {
err = e.SendWithTLS(server, sm.auth, tlsConfig)
} else {
err = e.SendWithStartTLS(server, sm.auth, tlsConfig)
}
return err
}
// Emailer contains the email sender, email content, and methods to construct message content.
type Emailer struct {
fromAddr, fromName string
sender emailClient
}
// Email stores content.
type Email struct {
subject string
html, text string
}
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
d, _ = strtime.Strftime(expiry, datePattern)
t, _ = strtime.Strftime(expiry, timePattern)
currentTime := time.Now()
if tzaware {
currentTime = currentTime.UTC()
}
_, _, days, hours, minutes, _ := timeDiff(expiry, currentTime)
if days != 0 {
expiresIn += fmt.Sprintf("%dd ", days)
}
if hours != 0 {
expiresIn += fmt.Sprintf("%dh ", hours)
}
if minutes != 0 {
expiresIn += fmt.Sprintf("%dm ", minutes)
}
expiresIn = strings.TrimSuffix(expiresIn, " ")
return
}
// NewEmailer configures and returns a new emailer.
func NewEmailer(app *appContext) *Emailer {
emailer := &Emailer{
fromAddr: app.config.Section("email").Key("address").String(),
fromName: app.config.Section("email").Key("from").String(),
}
method := app.config.Section("email").Key("method").String()
if method == "smtp" {
sslTls := false
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
sslTls = true
}
username := ""
if u := app.config.Section("smtp").Key("username").MustString(""); u != "" {
username = u
} else {
username = emailer.fromAddr
}
emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, app.config.Section("smtp").Key("password").String(), sslTls)
} else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
}
return emailer
}
// NewMailgun returns a Mailgun emailClient.
func (emailer *Emailer) NewMailgun(url, key string) {
sender := &Mailgun{
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
}
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages'
if strings.Contains(url, "messages") {
url = url[0:strings.LastIndex(url, "/")]
url = url[0:strings.LastIndex(url, "/")]
}
sender.client.SetAPIBase(url)
emailer.sender = sender
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool) {
emailer.sender = &SMTP{
auth: smtp.PlainAuth("", username, password, server),
server: server,
port: port,
sslTLS: sslTLS,
}
}
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("invite_emails").Key("subject").String(),
}
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("invite_emails").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
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,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: "Notice: Invite expired",
}
expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("notifications").Key("expiry_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"code": code,
"expiry": expiry,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) (*Email, error) {
email := &Email{
subject: "Notice: User created",
}
created := app.formatDatetime(invite.Created)
var tplAddress string
if app.config.Section("email").Key("no_username").MustBool(false) {
tplAddress = "n/a"
} else {
tplAddress = address
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("notifications").Key("created_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"code": code,
"username": username,
"address": tplAddress,
"time": created,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin"),
}
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("password_resets").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
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,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("deletion").Key("subject").MustString("Your account was deleted - Jellyfin"),
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("deletion").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"reason": reason,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
// calls the send method in the underlying emailClient.
func (emailer *Emailer) send(address string, email *Email) error {
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)
}

View File

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

54
go.mod
View File

@ -1,54 +0,0 @@
module github.com/hrfee/jfa-go
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/common => ./common
replace github.com/hrfee/jfa-go/ombi => ./ombi
require (
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/fsnotify/fsnotify v1.4.9
github.com/gin-contrib/pprof v1.3.0
github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e
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-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/docs v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/jfapi 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
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e
github.com/lithammer/shortuuid/v3 v3.0.4
github.com/logrusorgru/aurora/v3 v3.0.0
github.com/mailgun/mailgun-go/v4 v4.3.0
github.com/mailru/easyjson v0.7.6 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858
github.com/pkg/errors v0.9.1 // indirect
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.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/text v0.3.4 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0
gopkg.in/yaml.v2 v2.3.0 // indirect
)

431
go.sum
View File

@ -1,431 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
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/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=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0=
github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2 h1:xLG16iua01X7Gzms9045s2Y2niNpvSY/Zb1oBwgNYZY=
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ=
github.com/gin-contrib/static v0.0.0-20200815103939-31fb0c56a3d1 h1:plQYoJeO9lI8Ag0xZy7dDF8FMwIOHsQylKjcclknvIc=
github.com/gin-contrib/static v0.0.0-20200815103939-31fb0c56a3d1/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ=
github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e h1:8bZpGwoPxkaivQPrAbWl+7zjjUcbFUnYp7yQcx2r2N0=
github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-chi/chi v4.0.0+incompatible h1:SiLLEDyAkqNnw+T/uDTf3aFB9T4FTrwMpuYrgaRcnW4=
github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+jeTWn7tUa8o=
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/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=
github.com/go-openapi/spec v0.19.9 h1:9z9cbFuZJ7AcvOHKIY+f6Aevb4vObNDkTEyoMfO7rAc=
github.com/go-openapi/spec v0.19.9/go.mod h1:vqK/dIdLGCosfvYsQV3WfC7N3TiZSnGY2RZKoFK7X28=
github.com/go-openapi/spec v0.19.10 h1:pcNevfYytLaOQuTju0wm6OqcqU/E/pRwuSGigrLTI28=
github.com/go-openapi/spec v0.19.10/go.mod h1:vqK/dIdLGCosfvYsQV3WfC7N3TiZSnGY2RZKoFK7X28=
github.com/go-openapi/spec v0.19.12 h1:OO9WrvhDwtiMY/Opr1j1iFZzirI3JW4/bxNFRcntAr4=
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/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=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.9 h1:1IxuqvBUU3S2Bi4YC7tlP9SJF1gVpCvqN0T2Qof4azE=
github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY=
github.com/go-openapi/swag v0.19.10 h1:A1SWXruroGP15P1sOiegIPbaKio+G9N5TwWTFaVPmAU=
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-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=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+b2xXj0AU1Es7o=
github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.0 h1:72qIR/m8ybvL8L5TIyfgrigqkrw7kVYAvjEvpT85l70=
github.com/go-playground/validator/v10 v10.4.0/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/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=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/knz/strtime v0.0.0-20200318182718-be999391ffa9 h1:GQE1iatYDRrIidq4Zf/9ZzKWyrTk2sXOYc1JADbkAjQ=
github.com/knz/strtime v0.0.0-20200318182718-be999391ffa9/go.mod h1:4ZxfWkxwtc7dBeifERVVWRy9F9rTU9p0yCDgeCtlius=
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e h1:ViPE0JEOvtw5I0EGUiFSr2VNKGNU+3oBT+oHbDXHbxk=
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e/go.mod h1:4ZxfWkxwtc7dBeifERVVWRy9F9rTU9p0yCDgeCtlius=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/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/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=
github.com/labstack/gommon v0.2.9/go.mod h1:E8ZTmW9vw5az5/ZyHWCp0Lw4OH2ecsaBP1C/NKavGG4=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lithammer/shortuuid v1.0.0 h1:kdcbvjGVEgqeVeDIRtnANOi/F6ftbKrtbxY+cjQmK1Q=
github.com/lithammer/shortuuid v3.0.0+incompatible h1:NcD0xWW/MZYXEHa6ITy6kaXN5nwm/V115vj2YXfhS0w=
github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs=
github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4=
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
github.com/mailgun/mailgun-go v1.1.1 h1:mjMcm4qz+SbjAYbGJ6DKROViKtO5S0YjpuOUxQfdr2A=
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
github.com/mailgun/mailgun-go/v4 v4.1.3 h1:KLa5EZaOMMeyvY/lfAhWxv9ealB3mtUsMz0O9XmTtP0=
github.com/mailgun/mailgun-go/v4 v4.1.3/go.mod h1:R9kHUQBptF4iSEjhriCQizplCDwrnDShy8w/iPiOfaM=
github.com/mailgun/mailgun-go/v4 v4.2.0 h1:AAt7TwR98Pog7zAYK61SW7ikykFFmCovtix3vvS2cK4=
github.com/mailgun/mailgun-go/v4 v4.2.0/go.mod h1:R9kHUQBptF4iSEjhriCQizplCDwrnDShy8w/iPiOfaM=
github.com/mailgun/mailgun-go/v4 v4.3.0 h1:9nAF7LI3k6bfDPbMZQMMl63Q8/vs+dr1FUN8eR1XMhk=
github.com/mailgun/mailgun-go/v4 v4.3.0/go.mod h1:fWuBI2iaS/pSSyo6+EBpHjatQO3lV8onwqcRy7joSJI=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/mailru/easyjson v0.7.2 h1:V9ecaZWDYm7v9uJ15RZD6DajMu5sE0hdep0aoDwT9g4=
github.com/mailru/easyjson v0.7.2/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.3 h1:M6wcO9gFHCIPynXGu4iA+NMs//FCgFUWR2jxqV3/+Xk=
github.com/mailru/easyjson v0.7.3/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
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/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=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/gin-swagger v1.2.0 h1:YskZXEiv51fjOMTsXrOetAjrMDfFaXD79PEoQBOe2W0=
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
github.com/swaggo/gin-swagger v1.3.0 h1:eOmp7r57oUgZPw2dJOjcGNMse9cvXcI4tTqBcnZtPsI=
github.com/swaggo/gin-swagger v1.3.0/go.mod h1:oy1BRA6WvgtCp848lhxce7BnWH4C8Bxa0m5SkWx+cS0=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/swaggo/swag v1.6.7 h1:e8GC2xDllJZr3omJkm9YfmK0Y56+rMO3cg0JBKNz09s=
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
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=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.1.9 h1:SObrQTaSuP8WOv2WNCj8gECiNSJIUvk3Q7N26c96Gws=
github.com/ugorji/go v1.1.9/go.mod h1:chLrngdsg43geAaeId+nXO57YsDdl5OZqd/QtBiD19g=
github.com/ugorji/go v1.1.13/go.mod h1:jxau1n+/wyTGLQoCkjok9r5zFa/FxT6eI5HiHKQszjc=
github.com/ugorji/go v1.2.0 h1:6eXlzYLLwZwXroJx9NyqbYcbv/d93twiOzQLDewE6qM=
github.com/ugorji/go v1.2.0/go.mod h1:1ny++pKMXhLWrwWV5Nf+CbOuZJhMoaFD+0GMFfd8fEc=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.1.9 h1:J/7hhpkQwgypRNvaeh/T5gzJ2gEI/l8S3qyRrdEa1fA=
github.com/ugorji/go/codec v1.1.9/go.mod h1:+SWgpdqOgdW5sBaiDfkHilQ1SxQ1hBkq/R+kHfL7Suo=
github.com/ugorji/go/codec v1.1.13/go.mod h1:oNVt3Dq+FO91WNQ/9JnHKQP2QJxTzoN7wCBFCq1OeuU=
github.com/ugorji/go/codec v1.2.0 h1:As6RccOIlbm9wHuWYMlB30dErcI+4WiKWsYsmPkyrUw=
github.com/ugorji/go/codec v1.2.0/go.mod h1:dXvG35r7zTX6QImXOSFhGMmKtX+wJ7VTWzGvYQGIjBs=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200923182212-328152dc79b1 h1:Iu68XRPd67wN4aRGGWwwq6bZo/25jR6uu52l/j2KkUE=
golang.org/x/net v0.0.0-20200923182212-328152dc79b1/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200927032502-5d4f70055728 h1:5wtQIAulKU5AbLQOkjxl32UufnIOqgBX72pS0AV14H0=
golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0 h1:wBouT66WTYFXdxfVdz9sVWARVd/2vfGcmI45D2gj45M=
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 h1:5kGOVHlq0euqwzgTC9Vu15p6fV1Wi0ArVi8da2urnVg=
golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTiq8voBeTuweGVkY6Puw=
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/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=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1 h1:sIky/MyNRSHTrdxfsiUSS4WIAMvInbeXljJz+jDjeYE=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed h1:J22ig1FUekjjkmZUM7pTKixYm8DvrYsvrBZdunYeIuQ=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200929083018-4d22bbb62b3c h1:/h0vtH0PyU0xAoZJVcRw1k0Ng+U0JAy3QDiFmppIlIE=
golang.org/x/sys v0.0.0-20200929083018-4d22bbb62b3c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
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/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=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59 h1:QjA/9ArTfVTLfEhClDCG7SGrZkZixxWpwNCDiwJfh88=
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200923182640-463111b69878 h1:VUw1+Jf6KJPf82mbTQMia6HCnNMv2BbAipkEZ4KTcqQ=
golang.org/x/tools v0.0.0-20200923182640-463111b69878/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20200924182824-0f1c53950d78 h1:3JUoxVhcskhsIDEc7vg0MUUEpmPPN5TfG+E97z/Fn90=
golang.org/x/tools v0.0.0-20200924182824-0f1c53950d78/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20200929191002-f1e51e6b9437 h1:XSFqH8m531iIGazX5lrUC9j3slbwsZ1GFByqdUrLqmI=
golang.org/x/tools v0.0.0-20200929191002-f1e51e6b9437/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201008184944-d01b322e6f06 h1:w9ail9jFLaySAm61Zjhciu0LQ5i8YTy2pimlNLx4uuk=
golang.org/x/tools v0.0.0-20201008184944-d01b322e6f06/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201017001424-6003fad69a88 h1:ZB1XYzdDo7c/O48jzjMkvIjnC120Z9/CwgDWhePjQdQ=
golang.org/x/tools v0.0.0-20201017001424-6003fad69a88/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9 h1:sEvmEcJVKBNUvgCUClbUQeHOAa9U0I2Ce1BooMvVCY4=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201103235415-b653051172e4 h1:Qe0EMgvVYb6tmJhJHljCj3gS96hvSTkGNaIzp/ivq10=
golang.org/x/tools v0.0.0-20201103235415-b653051172e4/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201113202037-1643af1435f3 h1:7R7+wzd5VuLvCNyHZ/MG511kkoP/DBEzkbh8qUsFbY8=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
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/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=
gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww=
gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.60.0 h1:P5ZzC7RJO04094NJYlEnBdFK2wwmnCAy/+7sAzvWs60=
gopkg.in/ini.v1 v1.60.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.61.0 h1:LBCdW4FmFYL4s/vDZD1RQYX7oAR6IjujCYgMdbHBR10=
gopkg.in/ini.v1 v1.61.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.v3 v3.0.0-20200313102051-9f266ea9e77c/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=

View File

@ -1,6 +0,0 @@
# Images
This holds any images on the main README, and the base files for the icons and banner. The font used, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan.
"Go" text logo and Gopher image: Copyright 2018 The Go Authors. All rights reserved.
https://creativecommons.org/licenses/by/3.0/legalcode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 59 KiB

371
index.html Normal file
View File

@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="node_modules/a17t/dist/a17t.css">
<link rel="stylesheet" type="text/css" href="node_modules/icons.css/dist/icons.css">
<style>
.pageContainer {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
/*input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}*/
.contactBox {
color: grey;
}
#container {
margin-top: 5%;
margin-bottom: 5%;
}
.tabText {
font-size: 2rem;
}
.mb-1 {
margin-bottom: 1rem;
}
.pb-1 {
padding-bottom: 1rem;
}
.mr-1 {
margin-right: 1rem;
}
.mb-2 {
margin-bottom: 2rem;
}
.inv-codearea {
/*width: 40%;*/
}
.al {
text-align: left;
}
.inline-block {
display: inline-block;
}
.ar {
text-align: right;
}
.monospace {
background-color: inherit;
}
.mt-half {
margin-top: 0.5rem;
}
.table-header {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv {
overflow: hidden;
}
.inv-header {
display: flex;
justify-content: space-between;
}
.button-input {
display: flex;
justify-content: space-between;
}
/*.inv-details {
display: flex;
justify-content: space-between;
}*/
/*.invites {
transition: max-height 1s cubic-bezier(0.550, 0.055, 0.675, 0.190);
}*/
.inv-details {
}
.inv-row {
display: flex;
justify-content: space-between;
align-items: top;
}
/*.inv-profilearea {
align-items: top;
}*/
.ib {
display: inline-block;
}
.inv-profileselect {
min-width: 100%;
}
.inv-table {
font-size: 0.8rem;
}
.no-lp {
padding-left: 0px;
}
.block { display: block; }
.hiding {
animation: slide-out 0.1s cubic-bezier(0.550, 0.055, 0.675, 0.190) forwards;
}
.hidden {
display: none;
}
.visible {
display: block;
/*animation: fade-in 0.2s cubic-bezier(0.550, 0.055, 0.675, 0.190) forwards;*/
}
@keyframes fade-in {
from {
opacity: 0;
visibility: hidden;
}
to {
opacity: 1;
visibility: visible;
}
}
@keyframes slide-out {
from {
opacity: 1;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}
.toggle-details {
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); /* easeInOutQuart */
}
.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); /* easeInOutQuart */
}
.row {
display: flex;
flex-wrap: wrap;
}
.col {
flex: 1;
margin: 0.5rem;
}
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
}
.col {
flex: 45%;
}
}
.input {
box-sizing: border-box;
}
.button-lg {
height: 3rem;
}
.full-width {
box-sizing: border-box;
width: 100%;
justify-content: center;
}
</style>
<title>{{ .lang.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div class="pageContainer max-w-screen-lg px-6 py-4 mx-auto lg:mx-auto md:py-8">
<div class="relative mb-1">
<header class="flex flex-wrap items-center justify-between">
<div class="text-neutral-700">
<span class="tabText portal ~positive active">Invites</span>
<span class="tabText portal">Accounts</span>
<span class="tabText portal">Settings</span>
</div>
</header>
</div>
<div class="relative mb-1">
<div class="text-neutral-700">
<span class="button ~critical !normal mb-1">Logout</span>
<span class="button ~neutral !normal mb-1">Theme</span>
</div>
</div>
<div class="card ~neutral !high invites mb-1">
<span class="heading">Invites</span>
<div class="inv">
<div class="card ~neutral !normal inv-header mt-half">
<div class="inv-codearea">
<a href="#" class="code monospace mr-1">ZD8ZeC55Jcpmbtv54FuVM3</a>
<span class="button ~info !normal">Copy</span>
</div>
<div class="inv-infoarea">
<span class="inv-expiry mr-1">Expires in 30m</span>
<span class="button ~critical !normal">Delete</span>
<label>
<i class="icon icon-arrow-down not-rotated"></i>
<input type="checkbox" class="toggle-details">
</label>
</div>
</div>
<div class="card ~neutral !normal mt-half inv-details mt-half hidden">
<div class="inv-row">
<div class="inv-profilearea">
<p class="supra mb-1">Profile</p>
<div class="select ~neutral !normal inv-profileselect ib">
<select>
<option>Friends</option>
<option>Family</option>
<option>No Profile</option>
</select>
</div>
<p class="label supra">Notify on:</p>
<label class="switch block">
<input type="checkbox" class="inv-notify-expiry">
<span>On expiry</span>
</label>
<label class="switch block">
<input type="checkbox" class="inv-notify-creation">
<span>On user creation</span>
</label>
</div>
<div class="block">
<p class="supra mb-1">Created <strong class="inv-created">10/12/20 18:46</strong></p>
<p class="supra mb-1">Remaining uses <strong class="inv-remaining">8</strong></p>
</div>
<div class="card ~neutral !low">
<strong class="supra table-header">Created users</strong>
<table class="table inv-table">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>jeff</td>
<td>10/12/20 19:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="card ~neutral !high create-inv">
<span class="heading">Create</span>
<div class="row">
<div class="card ~neutral !low col">
<label class="label supra" for="inv-days">Days</label>
<div class="select ~neutral !normal mb-1">
<select id="inv-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="inv-hours">Hours</label>
<div class="select ~neutral !normal mb-1">
<select id="inv-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="inv-minutes">Minutes</label>
<div class="select ~neutral !normal mb-1">
<select id="inv-minutes">
<option>0</option>
</select>
</div>
</div>
<div class="card ~neutral !low col">
<label class="label supra" for="inv-uses">Number of uses</label>
<div class="button-input mb-1">
<input type="number" id="inv-uses" class="input ~neutral !normal mr-1" value=1>
<span id="inv-inf-uses" class="button ~neutral !normal" aria-label="Infinite uses"></span>
</div>
<label class="label supra">Profile</label>
<div class="select ~neutral !normal mb-1" id="inv-profile">
<select>
<option>Friends</option>
<option>Family</option>
<option>No Profile</option>
</select>
</div>
<label class="label supra">Send to</label>
<div class="button-input mb-1">
<input type="text" id="inv-email" class="input ~neutral !normal mr-1" placeholder="example@example.com">
<span id="inv-email-enabled" class="button ~neutral !normal" aria-label="Send to enabled"></span>
</div>
<span class="button ~positive !normal supra full-width button-lg">Create</span>
</div>
</div>
</div>
</div>
<script>
const whichTransitionEvent = () => {
const el = document.createElement("fakeElement");
if (el.style["animation"] !== void 0) {
return "animationend";
}
return "webkitAnimationEnd";
}
var transitionEnd = whichTransitionEvent();
const toggles = document.getElementsByClassName('toggle-details');
for (let toggle of toggles) {
console.log(toggle);
toggle.onclick = () => {
const el = toggle.parentElement.parentElement.parentElement.nextElementSibling
const container = toggle.parentElement.parentElement.parentElement.parentElement;
if (el.classList.contains("visible")) {
el.classList.toggle("visible");
//el.classList.toggle("hiding");
//const listenerFunc = function(event) { el.classList.toggle("hidden"); el.classList.toggle("hiding"); el.removeEventListener(transitionEnd, listenerFunc); };
el.classList.toggle("hidden");
// el.addEventListener(transitionEnd, listenerFunc, false);
} else {
el.classList.toggle("hidden");
//const height = parseInt(container.clientHeight, 10);
//const val = `${height + el.getBoundingClientRect().height}px`;
//console.log('showing ', val);
//container.style.height = val;
// let maxHeight = container.style.maxHeight || "0";
// const mheight = parseInt(maxHeight, 10)
// container.style.maxHeight = `${mheight + el.getBoundingClientRect().height}px`;
el.classList.toggle("visible");
}
toggle.previousElementSibling.classList.toggle("rotated");
toggle.previousElementSibling.classList.toggle("not-rotated");
//el.classList.toggle("visible");
//el.classList.toggle("hidden");
};
}
document.getElementById('inv-inf-uses').onclick = function () {
const uses = document.getElementById('inv-uses');
uses.disabled = (this.classList.contains('!normal'));
this.classList.toggle('!normal');
this.classList.toggle('!high');
this.classList.toggle('inf');
};
document.getElementById('inv-email-enabled').onclick = function () {
const uses = document.getElementById('inv-email');
uses.disabled = !(this.classList.contains('!normal'));
this.classList.toggle('!normal');
this.classList.toggle('!high');
this.classList.toggle('enabled');
};
</script>
</body>
</html>

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
}

View File

@ -1,47 +0,0 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>User Created</h3>
<p>A user was created using code {{ .code }}.</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>
</tr>
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
<th>{{ .username }}</th>
<th>{{ .address }}</th>
<th>{{ .time }}</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.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

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

View File

@ -1,36 +0,0 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Your account was deleted.</h3>
<p>Reason: <i>{{ .reason }}</i></p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

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

View File

@ -1,40 +0,0 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>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>
</mj-text>
<mj-button mj-class="blue bold">{{ .pin }}</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@ -1,10 +0,0 @@
Hi {{ .username }},
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.
PIN: {{ .pin }}
{{ .message }}

View File

@ -1,36 +0,0 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Invite Expired.</h3>
<p>Code {{ .code }} expired at {{ .expiry }}.</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.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

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

View File

@ -1,37 +0,0 @@
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 mjml in [f for f in local_path.iterdir() if f.is_file() and "mjml" in f.suffix]:
print(f"Compiling {mjml.name}")
fname = mjml.with_suffix(".html")
runcmd(f"npx mjml {str(mjml)} -o {str(fname)}")
if fname.is_file():
print("Done.")
html = [f for f in local_path.iterdir() if f.is_file() and "html" in f.suffix]
output = local_path.parent / "data"
for f in html:
shutil.copy(str(f), str(output / f.name))
print(f"Copied {f.name} to {str(output / f.name)}")
txtfile = f.with_suffix(".txt")
if txtfile.is_file():
shutil.copy(str(txtfile), str(output / txtfile.name))
print(f"Copied {txtfile.name} to {str(output / txtfile.name)}")
else:
print(
f"Warning: {txtfile.name} does not exist. Text versions of emails should be supplied."
)

View File

@ -1,39 +0,0 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>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>
</mj-text>
<mj-button mj-class="blue bold" href="{{ .invite_link }}">Setup your account</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@ -1,8 +0,0 @@
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.
{{ .invite_link }}
{{ .message }}

732
main.go
View File

@ -1,732 +0,0 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/gin-contrib/pprof"
"github.com/gin-contrib/static"
"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/ombi"
"github.com/lithammer/shortuuid/v3"
"github.com/logrusorgru/aurora/v3"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"gopkg.in/ini.v1"
)
// User is used for auth purposes.
type User struct {
UserID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}
type appContext struct {
// defaults *Config
config *ini.File
configPath string
configBasePath string
configBase map[string]interface{}
dataPath string
localPath string
cssFile string
bsVersion int
jellyfinLogin bool
users []User
invalidTokens []string
jf *jfapi.Jellyfin
authJf *jfapi.Jellyfin
ombi *ombi.Ombi
datePattern string
timePattern string
storage Storage
validator Validator
email *Emailer
info, debug, err *log.Logger
host string
port int
version string
quit chan os.Signal
lang Languages
URLBase string
}
// Languages stores the names and filenames of language files, and the index of that which is currently selected.
type Languages struct {
langFiles []os.FileInfo // Language filenames
langOptions []string // Language names
chosenIndex int
}
func (app *appContext) loadHTML(router *gin.Engine) {
customPath := app.config.Section("files").Key("html_templates").MustString("")
templatePath := filepath.Join(app.localPath, "templates")
htmlFiles, err := ioutil.ReadDir(templatePath)
if err != nil {
app.err.Fatalf("Couldn't access template directory: \"%s\"", filepath.Join(app.localPath, "templates"))
return
}
loadFiles := make([]string, len(htmlFiles))
for i, f := range htmlFiles {
if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) {
app.debug.Printf("Using default \"%s\"", f.Name())
loadFiles[i] = filepath.Join(templatePath, f.Name())
} else {
app.info.Printf("Using custom \"%s\"", f.Name())
loadFiles[i] = filepath.Join(filepath.Join(customPath, f.Name()))
}
}
router.LoadHTMLFiles(loadFiles...)
}
func generateSecret(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), err
}
func setGinLogger(router *gin.Engine, debugMode bool) {
if debugMode {
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("[GIN/DEBUG] %s: %s(%s) => %d in %s; %s\n",
param.TimeStamp.Format("15:04:05"),
param.Method,
param.Path,
param.StatusCode,
param.Latency,
func() string {
if param.ErrorMessage != "" {
return "Error: " + param.ErrorMessage
}
return ""
}(),
)
}))
} else {
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("[GIN] %s(%s) => %d\n",
param.Method,
param.Path,
param.StatusCode,
)
}))
}
}
var (
PLATFORM string = runtime.GOOS
SOCK string = "jfa-go.sock"
SRV *http.Server
RESTART chan bool
DATA, CONFIG, HOST *string
PORT *int
DEBUG *bool
TEST bool
SWAGGER *bool
)
func test(app *appContext) {
fmt.Printf("\n\n----\n\n")
settings := map[string]interface{}{
"server": app.jf.Server,
"server version": app.jf.ServerInfo.Version,
"server name": app.jf.ServerInfo.Name,
"authenticated?": app.jf.Authenticated,
"access token": app.jf.AccessToken,
"username": app.jf.Username,
}
for n, v := range settings {
fmt.Println(n, ":", v)
}
users, status, err := app.jf.GetUsers(false)
fmt.Printf("GetUsers: code %d err %s maplength %d\n", status, err, len(users))
fmt.Printf("View output? [y/n]: ")
var choice string
fmt.Scanln(&choice)
if strings.Contains(choice, "y") {
out, err := json.MarshalIndent(users, "", " ")
fmt.Print(string(out), err)
}
fmt.Printf("Enter a user to grab: ")
var username string
fmt.Scanln(&username)
user, status, err := app.jf.UserByName(username, false)
fmt.Printf("UserByName (%s): code %d err %s", username, status, err)
out, _ := json.MarshalIndent(user, "", " ")
fmt.Print(string(out))
}
func start(asDaemon, firstCall bool) {
// app encompasses essentially all useful functions.
app := new(appContext)
/*
set default config, data and local paths
also, confusing naming here. data_path is not the internal 'data' directory, rather the users .config/jfa-go folder.
local_path is the internal 'data' directory.
*/
userConfigDir, _ := os.UserConfigDir()
app.dataPath = filepath.Join(userConfigDir, "jfa-go")
app.configPath = filepath.Join(app.dataPath, "config.ini")
executable, _ := os.Executable()
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.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.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
flag.Parse()
if *SWAGGER {
os.Setenv("SWAGGER", "1")
}
if *DEBUG {
os.Setenv("DEBUG", "1")
}
}
if os.Getenv("SWAGGER") == "1" {
*SWAGGER = true
}
if os.Getenv("DEBUG") == "1" {
*DEBUG = true
}
// attempt to apply command line flags correctly
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.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.configPath = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
app.dataPath = v
}
os.Setenv("JFA_CONFIGPATH", app.configPath)
os.Setenv("JFA_DATAPATH", app.dataPath)
var firstRun bool
if _, err := os.Stat(app.dataPath); os.IsNotExist(err) {
os.Mkdir(app.dataPath, 0700)
}
if _, err := os.Stat(app.configPath); os.IsNotExist(err) {
firstRun = true
dConfigPath := filepath.Join(app.localPath, "config-default.ini")
var dConfig *os.File
dConfig, err = os.Open(dConfigPath)
if err != nil {
app.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
}
defer dConfig.Close()
var nConfig *os.File
nConfig, err := os.Create(app.configPath)
if err != nil {
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.configPath)
}
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.configPath)
}
lang := app.config.Section("ui").Key("language").MustString("en-us")
app.storage.lang.FormPath = filepath.Join(app.localPath, "lang", "form", lang+".json")
if _, err := os.Stat(app.storage.lang.FormPath); os.IsNotExist(err) {
app.storage.lang.FormPath = filepath.Join(app.localPath, "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)
// then from flag
if *DEBUG {
debugMode = true
}
if debugMode {
app.info.Print(aurora.Magenta("\n\nWARNING: Don't use debug mode in production, as it exposes pprof on the network.\n\n"))
app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
} else {
app.debug = log.New(ioutil.Discard, "", 0)
}
if asDaemon {
go func() {
socket := SOCK
os.Remove(socket)
listener, err := net.Listen("unix", socket)
if err != nil {
app.err.Fatalf("Couldn't establish socket connection at %s\n", SOCK)
}
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
os.Remove(socket)
os.Exit(1)
}()
defer func() {
listener.Close()
os.Remove(SOCK)
}()
for {
con, err := listener.Accept()
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", socket, err)
continue
}
buf := make([]byte, 512)
nr, err := con.Read(buf)
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", socket, err)
continue
}
command := string(buf[0:nr])
if command == "stop" {
app.shutdown()
}
}
}()
}
if !firstRun {
app.host = app.config.Section("ui").Key("host").String()
app.port = app.config.Section("ui").Key("port").MustInt(8056)
if *HOST != app.host && *HOST != "" {
app.host = *HOST
}
if *PORT != app.port && *PORT > 0 {
app.port = *PORT
}
if h := os.Getenv("JFA_HOST"); h != "" {
app.host = h
if p := os.Getenv("JFA_PORT"); p != "" {
var port int
_, err := fmt.Sscan(p, &port)
if err == nil {
app.port = port
}
}
}
address = fmt.Sprintf("%s:%d", app.host, app.port)
app.debug.Printf("Loaded config file \"%s\"", app.configPath)
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.Println("Loading storage")
app.storage.invite_path = app.config.Section("files").Key("invites").String()
app.storage.loadInvites()
app.storage.emails_path = app.config.Section("files").Key("emails").String()
app.storage.loadEmails()
app.storage.policy_path = app.config.Section("files").Key("user_template").String()
app.storage.loadPolicy()
app.storage.configuration_path = app.config.Section("files").Key("user_configuration").String()
app.storage.loadConfiguration()
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
app.storage.loadDisplayprefs()
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()
for _, path := range [3]string{app.storage.policy_path, app.storage.configuration_path, app.storage.displayprefs_path} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
dir, fname := filepath.Split(path)
newFname := strings.Replace(fname, ".json", ".old.json", 1)
err := os.Rename(path, filepath.Join(dir, newFname))
if err != nil {
app.err.Fatalf("Failed to rename %s: %s", fname, err)
}
}
}
app.info.Println("In case of a problem, your original files have been renamed to <file>.old.json")
app.storage.storeProfiles()
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String()
app.storage.loadOmbiTemplate()
ombiServer := app.config.Section("ombi").Key("server").String()
app.ombi = ombi.NewOmbi(
ombiServer,
app.config.Section("ombi").Key("api_key").String(),
common.NewTimeoutHandler("Ombi", ombiServer, true),
)
}
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": "",
}
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
app.cssFile = val
}
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := generateSecret(16)
if err != nil {
app.err.Fatal(err)
}
os.Setenv("JFA_SECRET", secret)
app.jellyfinLogin = true
if val, _ := app.config.Section("ui").Key("jellyfin_login").Bool(); !val {
app.jellyfinLogin = false
user := User{}
user.UserID = shortuuid.New()
user.Username = app.config.Section("ui").Key("username").String()
user.Password = app.config.Section("ui").Key("password").String()
app.users = append(app.users, user)
} else {
app.debug.Println("Using Jellyfin for authentication")
}
server := app.config.Section("jellyfin").Key("server").String()
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
app.jf, _ = jfapi.NewJellyfin(
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),
cacheTimeout,
)
var status int
_, status, err = app.jf.Authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
if status != 200 || err != nil {
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
}
app.info.Printf("Authenticated with %s", server)
// from 10.7.0, jellyfin hyphenates 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 checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") {
noHyphens := true
for id := range app.storage.emails {
if strings.Contains(id, "-") {
noHyphens = false
break
}
}
if noHyphens {
app.info.Println(aurora.Yellow("From Jellyfin 10.7.0 onwards, user IDs are hyphenated.\nYour emails.json file will be modified to match this new format.\nA backup will be placed next to the file.\n"))
time.Sleep(time.Second * time.Duration(3))
newEmails, status, err := app.upgradeEmailStorage(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.authJf, _ = jfapi.NewJellyfin(server, "jfa-go", app.version, "auth", "auth", common.NewTimeoutHandler("Jellyfin", server, true), cacheTimeout)
app.loadStrftime()
validatorConf := ValidatorConf{
"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 {
validatorConf[key] = 0
}
}
app.validator.init(validatorConf)
if TEST {
test(app)
os.Exit(0)
}
inviteDaemon := newRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.run()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
go app.StartPWR()
}
} else {
debugMode = false
address = "0.0.0.0:8056"
}
app.info.Println("Loading routes")
if debugMode {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
setGinLogger(router, debugMode)
router.Use(gin.Recovery())
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.localPath, "static"), false)))
app.loadHTML(router)
router.NoRoute(app.NoRouteHandler)
if debugMode {
app.debug.Println("Loading pprof")
pprof.Register(router)
}
if !firstRun {
router.GET("/", app.AdminPage)
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.localPath, "static"), 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"))
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
api := router.Group("/", app.webAuth())
router.POST("/logout", app.Logout)
api.DELETE("/users", app.DeleteUser)
api.GET("/users", app.GetUsers)
api.POST("/users", app.NewUserAdmin)
api.POST("/invites", app.GenerateInvite)
api.GET("/invites", app.GetInvites)
api.DELETE("/invites", app.DeleteInvite)
api.POST("/invites/profile", app.SetProfile)
api.GET("/profiles", app.GetProfiles)
api.POST("/profiles/default", app.SetDefaultProfile)
api.POST("/profiles", app.CreateProfile)
api.DELETE("/profiles", app.DeleteProfile)
api.POST("/invites/notify", app.SetNotify)
api.POST("/users/emails", app.ModifyEmails)
// api.POST("/setDefaults", app.SetDefaults)
api.POST("/users/settings", app.ApplySettings)
api.GET("/config", app.GetConfig)
api.POST("/config", app.ModifyConfig)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET("/ombi/users", app.OmbiUsers)
api.POST("/ombi/defaults", app.SetOmbiDefaults)
}
app.info.Printf("Starting router @ %s", address)
} else {
router.GET("/", func(gc *gin.Context) {
gc.HTML(200, "setup.html", gin.H{})
})
router.POST("/jellyfin/test", app.TestJF)
router.POST("/config", app.ModifyConfig)
app.info.Printf("Loading setup @ %s", address)
}
SRV = &http.Server{
Addr: address,
Handler: router,
}
go func() {
if err := SRV.ListenAndServe(); err != nil {
app.err.Printf("Failure serving: %s", err)
}
}()
app.quit = make(chan os.Signal)
signal.Notify(app.quit, os.Interrupt)
go func() {
for range app.quit {
app.shutdown()
}
}()
for range RESTART {
cntx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err := SRV.Shutdown(cntx); err != nil {
app.err.Fatalf("Server shutdown error: %s", err)
}
return
}
}
func (app *appContext) shutdown() {
app.info.Println("Shutting down...")
cntx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err := SRV.Shutdown(cntx); err != nil {
app.err.Fatalf("Server shutdown error: %s", err)
}
os.Exit(1)
}
func flagPassed(name string) (found bool) {
for _, f := range os.Args {
if f == name {
found = true
}
}
return
}
// @title jfa-go internal API
// @version 0.2.0
// @description API for the jfa-go frontend
// @contact.name Harvey Tindall
// @contact.email hrfee@protonmail.ch
// @license.name MIT
// @license.url https://raw.githubusercontent.com/hrfee/jfa-go/main/LICENSE
// @BasePath /
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @securityDefinitions.basic getTokenAuth
// @name getTokenAuth
// @tag.name Auth
// @tag.description --------Get a token here first!--------
// @tag.name Users
// @tag.description Jellyfin user related operations.
// @tag.name Invites
// @tag.description Invite related operations.
// @tag.name Profiles & Settings
// @tag.description Profile and settings related operations.
// @tag.name Configuration
// @tag.description jfa-go settings.
// @tag.name Ombi
// @tag.description Ombi related operations.
// @tag.name Other
// @tag.description Things that dont fit elsewhere.
func printVersion() {
fmt.Print(aurora.Sprintf(aurora.Magenta("jfa-go version: %s (%s)\n"), aurora.BrightWhite(VERSION), aurora.White(COMMIT)))
}
func main() {
printVersion()
folder := "/tmp"
if PLATFORM == "windows" {
folder = os.Getenv("TEMP")
}
SOCK = filepath.Join(folder, SOCK)
fmt.Println("Socket:", SOCK)
if flagPassed("test") {
TEST = true
}
if flagPassed("start") {
args := []string{}
for i, f := range os.Args {
if f == "start" {
args = append(args, "daemon")
} else if i != 0 {
args = append(args, f)
}
}
cmd := exec.Command(os.Args[0], args...)
cmd.Start()
os.Exit(1)
} else if flagPassed("stop") {
con, err := net.Dial("unix", SOCK)
if err != nil {
fmt.Printf("Couldn't dial socket %s, are you sure jfa-go is running?\n", SOCK)
os.Exit(1)
}
_, err = con.Write([]byte("stop"))
if err != nil {
fmt.Printf("Couldn't send command to socket %s, are you sure jfa-go is running?\n", SOCK)
os.Exit(1)
}
fmt.Println("Sent.")
} else if flagPassed("daemon") {
start(true, true)
} else {
RESTART = make(chan bool, 1)
start(false, true)
for {
printVersion()
start(false, false)
}
}
}

129
models.go
View File

@ -1,129 +0,0 @@
package main
type stringResponse struct {
Response string `json:"response" example:"message"`
Error string `json:"error" example:"errorDescription"`
}
type boolResponse struct {
Success bool `json:"success" example:"false"`
Error bool `json:"error" example:"true"`
}
type newUserDTO struct {
Username string `json:"username" example:"jeff" binding:"required"` // User's username
Password string `json:"password" example:"guest" binding:"required"` // User's password
Email string `json:"email" example:"jeff@jellyf.in"` // User's email address
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
}
type deleteUserDTO struct {
Users []string `json:"users" binding:"required"` // List of usernames to delete
Notify bool `json:"notify"` // Whether to notify users of deletion
Reason string `json:"reason"` // Account deletion reason (for notification)
}
type generateInviteDTO struct {
Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes
Email string `json:"email" example:"jeff@jellyf.in"` // Send invite to this address
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
Profile string `json:"profile" example:"DefaultProfile"` // Name of profile to apply on this invite
}
type inviteProfileDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
Profile string `json:"profile" example:"DefaultProfile"` // Profile to use
}
type profileDTO struct {
Admin bool `json:"admin" example:"false"` // Whether profile has admin rights or not
LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
}
type getProfilesDTO struct {
Profiles map[string]profileDTO `json:"profiles"`
DefaultProfile string `json:"default_profile"`
}
type profileChangeDTO struct {
Name string `json:"name" example:"DefaultProfile" binding:"required"` // Name of the profile
}
type newProfileDTO struct {
Name string `json:"name" example:"DefaultProfile" binding:"required"` // Name of the profile
ID string `json:"id" example:"kasdjlaskjd342342" binding:"required"` // ID of user to source settings from
Homescreen bool `json:"homescreen" example:"true"` // Whether to store homescreen layout or not
}
type inviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
Created string `json:"created" example:"01/01/20 12:00"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
}
type getInvitesDTO struct {
Profiles []string `json:"profiles"` // List of profiles (name only)
Invites []inviteDTO `json:"invites"` // List of invites
}
// fake DTO, if i actually used this the code would be a lot longer
type setNotifyValues map[string]struct {
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
}
type setNotifyDTO map[string]setNotifyValues
type deleteInviteDTO struct {
Code string `json:"code" example:"skjadajd43234s"` // Code of invite to delete
}
type respUser struct {
ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user
Name string `json:"name" example:"jeff"` // Username of user
Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available)
LastActive string `json:"last_active"` // Time of last activity on Jellyfin
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
}
type getUsersDTO struct {
UserList []respUser `json:"users"`
}
type ombiUser struct {
Name string `json:"name,omitempty" example:"jeff"` // Name of Ombi user
ID string `json:"id" example:"djgkjdg7dkjfsj8"` // userID of Ombi user
}
type ombiUsersDTO struct {
Users []ombiUser `json:"users"`
}
type modifyEmailsDTO map[string]string
type userSettingsDTO struct {
From string `json:"from"` // Whether to apply from "user" or "profile"
Profile string `json:"profile"` // Name of profile (if from = "profile")
ApplyTo []string `json:"apply_to"` // Users to apply settings to
ID string `json:"id"` // ID of user (if from = "user")
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
}
type errorListDTO map[string]map[string]string
type configDTO map[string]interface{}

View File

@ -1,5 +0,0 @@
module github.com/hrfee/jfa-go/ombi
replace github.com/hrfee/jfa-go/common => ../common
go 1.15

View File

@ -1,221 +0,0 @@
package ombi
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
)
// Ombi represents a running Ombi instance.
type Ombi struct {
server, key string
header map[string]string
httpClient *http.Client
userCache []map[string]interface{}
cacheExpiry time.Time
cacheLength int
timeoutHandler common.TimeoutHandler
}
// NewOmbi returns an Ombi object.
func NewOmbi(server, key string, timeoutHandler common.TimeoutHandler) *Ombi {
return &Ombi{
server: server,
key: key,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
header: map[string]string{
"ApiKey": key,
},
cacheLength: 30,
cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler,
}
}
// does a GET and returns the response as a string.
func (ombi *Ombi) getJSON(url string, params map[string]string) (string, int, error) {
if ombi.key == "" {
return "", 401, fmt.Errorf("No API key provided")
}
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 ombi.header {
req.Header.Add(name, value)
}
resp, err := ombi.httpClient.Do(req)
defer ombi.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 {
return "", 401, fmt.Errorf("Invalid API Key")
}
return "", 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
}
buf := new(strings.Builder)
_, err = io.Copy(buf, data)
if err != nil {
return "", 500, err
}
return buf.String(), resp.StatusCode, nil
}
// does a POST and optionally returns response as string. Returns a string instead of an io.reader bcs i couldn't get it working otherwise.
func (ombi *Ombi) send(mode string, url string, data map[string]interface{}, response bool) (string, int, error) {
responseText := ""
params, _ := json.Marshal(data)
req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params))
req.Header.Add("Content-Type", "application/json")
for name, value := range ombi.header {
req.Header.Add(name, value)
}
resp, err := ombi.httpClient.Do(req)
defer ombi.timeoutHandler()
if err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201) {
if resp.StatusCode == 401 {
return "", 401, fmt.Errorf("Invalid API Key")
}
return responseText, resp.StatusCode, err
}
if response {
defer resp.Body.Close()
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err = io.Copy(buf, out)
if err != nil {
return "", 500, err
}
responseText = buf.String()
}
return responseText, resp.StatusCode, nil
}
func (ombi *Ombi) post(url string, data map[string]interface{}, response bool) (string, int, error) {
return ombi.send("POST", url, data, response)
}
func (ombi *Ombi) put(url string, data map[string]interface{}, response bool) (string, int, error) {
return ombi.send("PUT", url, data, response)
}
// ModifyUser applies the given modified user object to the corresponding user.
func (ombi *Ombi) ModifyUser(user map[string]interface{}) (status int, err error) {
if _, ok := user["id"]; !ok {
err = fmt.Errorf("No ID provided")
return
}
_, status, err = ombi.put(ombi.server+"/api/v1/Identity", user, false)
return
}
// DeleteUser deletes the user corresponding to the given ID.
func (ombi *Ombi) DeleteUser(id string) (code int, err error) {
url := fmt.Sprintf("%s/api/v1/Identity/%s", ombi.server, id)
req, _ := http.NewRequest("DELETE", url, nil)
req.Header.Add("Content-Type", "application/json")
for name, value := range ombi.header {
req.Header.Add(name, value)
}
resp, err := ombi.httpClient.Do(req)
defer ombi.timeoutHandler()
return resp.StatusCode, err
}
// UserByID returns the user corresponding to the provided ID.
func (ombi *Ombi) UserByID(id string) (result map[string]interface{}, code int, err error) {
resp, code, err := ombi.getJSON(fmt.Sprintf("%s/api/v1/Identity/User/%s", ombi.server, id), nil)
json.Unmarshal([]byte(resp), &result)
return
}
// GetUsers returns all users on the Ombi instance.
func (ombi *Ombi) GetUsers() ([]map[string]interface{}, int, error) {
if time.Now().After(ombi.cacheExpiry) {
resp, code, err := ombi.getJSON(fmt.Sprintf("%s/api/v1/Identity/Users", ombi.server), nil)
var result []map[string]interface{}
json.Unmarshal([]byte(resp), &result)
ombi.userCache = result
if (code == 200 || code == 204) && err == nil {
ombi.cacheExpiry = time.Now().Add(time.Minute * time.Duration(ombi.cacheLength))
}
return result, code, err
}
return ombi.userCache, 200, nil
}
// Strip these from a user when saving as a template.
// We also need to strip userQualityProfiles{"id", "userId"}
var stripFromOmbi = []string{
"alias",
"emailAddress",
"hasLoggedIn",
"id",
"lastLoggedIn",
"password",
"userName",
}
// TemplateByID returns a template based on the user corresponding to the provided ID's settings.
func (ombi *Ombi) TemplateByID(id string) (result map[string]interface{}, code int, err error) {
result, code, err = ombi.UserByID(id)
if err != nil || code != 200 {
return
}
for _, key := range stripFromOmbi {
if _, ok := result[key]; ok {
delete(result, key)
}
}
if qp, ok := result["userQualityProfiles"].(map[string]interface{}); ok {
delete(qp, "id")
delete(qp, "userId")
result["userQualityProfiles"] = qp
}
return
}
// NewUser creates a new user with the given username, password and email address.
func (ombi *Ombi) NewUser(username, password, email string, template map[string]interface{}) ([]string, int, error) {
url := fmt.Sprintf("%s/api/v1/Identity", ombi.server)
user := template
user["userName"] = username
user["password"] = password
user["emailAddress"] = email
resp, code, err := ombi.post(url, user, true)
var data map[string]interface{}
json.Unmarshal([]byte(resp), &data)
if err != nil || code != 200 {
var lst []string
if data["errors"] != nil {
lst = data["errors"].([]string)
}
return lst, code, err
}
return nil, code, err
}

3104
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,15 @@
{
"name": "jellyfin-accounts",
"name": "jfa-go-a17t",
"version": "1.0.0",
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
},
"homepage": "https://github.com/hrfee/jellyfin-accounts#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",
"esbuild": "^0.7.8",
"lodash": "^4.17.19",
"mjml": "^4.6.3",
"postcss-cli": "^7.1.1",
"typescript": "^4.0.3"
"a17t": "^0.3.0",
"icons.css": "git+https://github.com/picturepan2/icons.css.git"
}
}

View File

@ -1,105 +0,0 @@
package main
import (
"encoding/json"
"io/ioutil"
"os"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
func (app *appContext) StartPWR() {
app.info.Println("Starting password reset daemon")
path := app.config.Section("password_resets").Key("watch_directory").String()
if _, err := os.Stat(path); os.IsNotExist(err) {
app.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path)
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
app.err.Printf("Couldn't initialise password reset daemon")
return
}
defer watcher.Close()
done := make(chan bool)
go pwrMonitor(app, watcher)
err = watcher.Add(path)
if err != nil {
app.err.Printf("Failed to start password reset daemon: %s", err)
}
<-done
}
// 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"`
}
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
var pwr PasswordReset
data, err := ioutil.ReadFile(event.Name)
if err != nil {
return
}
err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil {
return
}
app.info.Printf("New password reset for user \"%s\"", pwr.Username)
if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
user, status, err := app.jf.UserByName(pwr.Username, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
return
}
app.storage.loadEmails()
var address string
uid := user["Id"]
if uid == nil {
app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username)
app.debug.Printf("user maplength: %d", len(user))
return
}
addr, ok := app.storage.emails[user["Id"].(string)]
if !ok || addr == nil {
app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
return
}
address = addr.(string)
msg, err := app.email.constructReset(pwr, app)
if err != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else if err := app.email.send(address, msg); err != nil {
app.err.Printf("Failed to send password reset email to \"%s\"", address)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else {
app.info.Printf("Sent password reset email to \"%s\"", address)
}
} else {
app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
app.err.Printf("Password reset daemon: %s", err)
}
}
}

View File

@ -1,70 +0,0 @@
package main
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
}
// This isn't used, its for swagger
type PasswordValidation struct {
Characters bool `json:"length,omitempty"` // Number of characters
Lowercase bool `json:"lowercase,omitempty"` // Number of lowercase characters
Uppercase bool `json:"uppercase,omitempty"` // Number of uppercase characters
Numbers bool `json:"number,omitempty"` // Number of numbers
Specials bool `json:"special,omitempty"` // Number of special characters
}
func (vd *Validator) validate(password string) map[string]bool {
count := map[string]int{}
for key := range vd.criteria {
count[key] = 0
}
for _, c := range password {
count["length"] += 1
if unicode.IsUpper(c) {
count["uppercase"] += 1
} else if unicode.IsLower(c) {
count["lowercase"] += 1
} else if unicode.IsNumber(c) {
count["number"] += 1
} else {
for _, s := range vd.specialChars {
if c == s {
count["special"] += 1
}
}
}
}
results := map[string]bool{}
for criterion, num := range count {
if num < vd.criteria[criterion] {
results[criterion] = false
} else {
results[criterion] = true
}
}
return results
}
func (vd *Validator) getCriteria() ValidatorConf {
criteria := ValidatorConf{}
for key, num := range vd.criteria {
if num != 0 {
criteria[key] = num
}
}
return criteria
}

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,4 +0,0 @@
@import "../jf-pre.scss";
@import "../../node_modules/bootstrap4/scss/bootstrap";
@import "../jf-post.scss";
@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,7 +0,0 @@
.btn-close {
filter: invert(80%);
}
@import "../jf-pre.scss";
@import "../../node_modules/bootstrap/scss/bootstrap";
@import "../jf-post.scss";
@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

@ -1,41 +0,0 @@
.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;
}

View File

@ -1,101 +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";

View File

@ -1,26 +0,0 @@
package main
import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jfapi"
)
type testReq struct {
Host string `json:"jfHost"`
Username string `json:"jfUser"`
Password string `json:"jfPassword"`
}
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)
_, 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)
gc.JSON(401, map[string]bool{"success": false})
return
}
gc.JSON(200, map[string]bool{"success": true})
}

View File

@ -1,213 +0,0 @@
package main
import (
"encoding/json"
"io/ioutil"
"log"
"strconv"
"strings"
"time"
)
type Storage struct {
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path string
invites Invites
profiles map[string]Profile
defaultProfile string
emails, policy, configuration, displayprefs, ombi_template map[string]interface{}
lang Lang
}
type Lang struct {
FormPath string
Form map[string]interface{}
}
// timePattern: %Y-%m-%dT%H:%M:%S.%f
type Profile struct {
Admin bool `json:"admin,omitempty"`
LibraryAccess string `json:"libraries,omitempty"`
FromUser string `json:"fromUser,omitempty"`
Policy map[string]interface{} `json:"policy,omitempty"`
Configuration map[string]interface{} `json:"configuration,omitempty"`
Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
Default bool `json:"default,omitempty"`
}
type Invite struct {
Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
Email string `json:"email"`
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
}
type Invites map[string]Invite
func (st *Storage) loadInvites() error {
return loadJSON(st.invite_path, &st.invites)
}
func (st *Storage) storeInvites() error {
return storeJSON(st.invite_path, st.invites)
}
func (st *Storage) loadLang() error {
err := loadJSON(st.lang.FormPath, &st.lang.Form)
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
}
strings["validationStrings"] = string(vS)
st.lang.Form["strings"] = strings
return nil
}
func (st *Storage) loadEmails() error {
return loadJSON(st.emails_path, &st.emails)
}
func (st *Storage) storeEmails() error {
return storeJSON(st.emails_path, st.emails)
}
func (st *Storage) loadPolicy() error {
return loadJSON(st.policy_path, &st.policy)
}
func (st *Storage) storePolicy() error {
return storeJSON(st.policy_path, st.policy)
}
func (st *Storage) loadConfiguration() error {
return loadJSON(st.configuration_path, &st.configuration)
}
func (st *Storage) storeConfiguration() error {
return storeJSON(st.configuration_path, st.configuration)
}
func (st *Storage) loadDisplayprefs() error {
return loadJSON(st.displayprefs_path, &st.displayprefs)
}
func (st *Storage) storeDisplayprefs() error {
return storeJSON(st.displayprefs_path, st.displayprefs)
}
func (st *Storage) loadOmbiTemplate() error {
return loadJSON(st.ombi_path, &st.ombi_template)
}
func (st *Storage) storeOmbiTemplate() error {
return storeJSON(st.ombi_path, st.ombi_template)
}
func (st *Storage) loadProfiles() error {
err := loadJSON(st.profiles_path, &st.profiles)
for name, profile := range st.profiles {
if profile.Default {
st.defaultProfile = name
}
change := false
if profile.Policy["IsAdministrator"] != nil {
profile.Admin = profile.Policy["IsAdministrator"].(bool)
change = true
}
if profile.Policy["EnabledFolders"] != nil {
length := len(profile.Policy["EnabledFolders"].([]interface{}))
if length == 0 {
profile.LibraryAccess = "All"
} else {
profile.LibraryAccess = strconv.Itoa(length)
}
change = true
}
if profile.FromUser == "" {
profile.FromUser = "Unknown"
change = true
}
if change {
st.profiles[name] = profile
}
}
if st.defaultProfile == "" {
for n := range st.profiles {
st.defaultProfile = n
}
}
return err
}
func (st *Storage) storeProfiles() error {
return storeJSON(st.profiles_path, st.profiles)
}
func (st *Storage) migrateToProfile() error {
st.loadPolicy()
st.loadConfiguration()
st.loadDisplayprefs()
st.loadProfiles()
st.profiles["Default"] = Profile{
Policy: st.policy,
Configuration: st.configuration,
Displayprefs: st.displayprefs,
}
return st.storeProfiles()
}
func loadJSON(path string, obj interface{}) error {
var file []byte
var err error
file, err = ioutil.ReadFile(path)
if err != nil {
file = []byte("{}")
}
err = json.Unmarshal(file, &obj)
if err != nil {
log.Printf("ERROR: Failed to read \"%s\": %s", path, err)
}
return err
}
func storeJSON(path string, obj interface{}) error {
data, err := json.Marshal(obj)
if err != nil {
return err
}
err = ioutil.WriteFile(path, data, 0644)
if err != nil {
log.Printf("ERROR: Failed to write to \"%s\": %s", path, err)
}
return err
}
// JF 10.7.0 added hyphens to user IDs like this and we need to upgrade email storage to match it:
// [8 chars]-[4]-[4]-[4]-[12]
// This seems consistent, but we'll grab IDs from jellyfin just in case theres some variation.
func (app *appContext) upgradeEmailStorage(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,196 +0,0 @@
import { checkCheckboxes, populateUsers, populateRadios, changeEmail, validateEmail } from "./modules/accounts.js";
import { _post, _get, _delete, rmAttr, addAttr, createEl } from "./modules/common.js";
import { populateProfiles } from "./modules/settings.js";
import { Focus, Unfocus, storeDefaults } from "./modules/admin.js";
interface aWindow extends Window {
changeEmail(icon: HTMLElement, id: string): void;
}
declare var window: aWindow;
window.changeEmail = changeEmail;
(<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,211 +0,0 @@
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";
interface aWindow extends Window {
toClipboard(str: string): void;
}
declare var window: aWindow;
interface TabSwitcher {
els: Array<HTMLDivElement>;
tabButtons: Array<HTMLAnchorElement>;
focus: (el: number) => void;
invites: () => void;
accounts: () => void;
settings: () => void;
}
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.bsVersion = window.bs5 ? 5 : 4
if (window.bs5) {
window.BS = new BS5;
} else {
window.BS = new BS4;
window.BS.Compat();
}
window.Modals = {} as BSModals;
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');
tabs.tabButtons[0].onclick = tabs.invites;
tabs.tabButtons[1].onclick = tabs.accounts;
tabs.tabButtons[2].onclick = tabs.settings;
tabs.invites();
// 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)";
}
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);
}
var availableProfiles: Array<string>;
window["token"] = "";
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);
}
}
function login(username: string, password: string, modal: boolean, button?: HTMLButtonElement, run?: (arg0: number) => void): void {
const req = new XMLHttpRequest();
req.responseType = 'json';
let url = window.URLBase;
const refresh = (username == "" && password == "");
if (refresh) {
url += "/token/refresh";
} else {
url += "/token/login";
}
req.open("GET", url, true);
if (!refresh) {
req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
}
req.onreadystatechange = function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
let errorMsg = this.response["error"];
if (!errorMsg) {
errorMsg = "Unknown 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);
} else {
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();
}
Focus(document.getElementById('logoutButton'));
}
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;
};
generateInvites(true);
login("", "", false, null, (status: number): void => {
if (!(status == 200 || status == 204)) {
window.Modals.login.show();
}
});
(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,153 +0,0 @@
import { serializeForm, _post, _get, _delete, addAttr, rmAttr } from "./modules/common.js";
import { BS5 } from "./modules/bs5.js";
import { BS4 } from "./modules/bs4.js";
interface formWindow extends Window {
usernameEnabled: boolean;
validationStrings: pwValStrings;
checkPassword(): void;
invalidPassword: string;
}
declare var window: formWindow;
interface pwValString {
singular: string;
plural: string;
}
interface pwValStrings {
length, uppercase, lowercase, number, special: pwValString;
}
var defaultPwValStrings: pwValStrings = {
length: {
singular: "Must have at least {n} character",
plural: "Must have a least {n} characters"
},
uppercase: {
singular: "Must have at least {n} uppercase character",
plural: "Must have at least {n} uppercase characters"
},
lowercase: {
singular: "Must have at least {n} lowercase character",
plural: "Must have at least {n} lowercase characters"
},
number: {
singular: "Must have at least {n} number",
plural: "Must have at least {n} numbers"
},
special: {
singular: "Must have at least {n} special character",
plural: "Must have at least {n} special characters"
}
}
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 "";
} 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;
}
};
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;
}
}
window.BS = window.bs5 ? new BS5 : new BS4;
var successBox: BSModal = window.BS.newModal('successBox');;
var code = window.location.href.split('/').pop();
(document.getElementById('accountForm') as HTMLFormElement).addEventListener('submit', (event: any): boolean => {
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>
`;
} 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();
}
}
}
}, true);
return false;
});
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");
}
}
}

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,163 +0,0 @@
import { _get, _post, _delete, createEl } from "../modules/common.js";
import { Focus, Unfocus } from "../modules/admin.js";
interface aWindow extends Window {
checkCheckboxes: () => void;
}
declare var window: aWindow;
export const validateEmail = (email: string): boolean => /\S+@\S+\.\S+/.test(email);
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';
} else {
deleteButton.textContent = 'Delete Users';
}
}
}
window.checkCheckboxes = checkCheckboxes;
export function 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);
};
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>
`;
return entry.outerHTML;
};
const template = (id: string, username: string, email: string, lastActive: string, admin: boolean): string => {
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>
`;
};
_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);
}
Focus(acList.parentNode.querySelector('thead'));
acList.replaceWith(accountsList);
}
});
}
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);
}
}

View File

@ -1,62 +0,0 @@
import { rmAttr, addAttr, _post, _get, _delete, createEl } from "../modules/common.js";
export const Focus = (el: HTMLElement): void => rmAttr(el, 'unfocused');
export const Unfocus = (el: HTMLElement): void => addAttr(el, 'unfocused');
export function storeDefaults(users: string | Array<string>): void {
const button = document.getElementById('storeDefaults') 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 data = { "homescreen": false };
if ((document.getElementById('defaultsSource') as HTMLSelectElement).value == 'profile') {
data["from"] = "profile";
data["profile"] = (document.getElementById('profileSelect') as HTMLSelectElement).value;
} else {
const radio = document.querySelector('input[name=defaultRadios]:checked') as HTMLInputElement
let id = radio.id.replace("default_", "");
data["from"] = "user";
data["id"] = id;
}
if (users != "all") {
data["apply_to"] = users;
}
if ((document.getElementById('storeDefaultHomescreen') as HTMLInputElement).checked) {
data["homescreen"] = true;
}
_post("/users/settings", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-success");
button.disabled = false;
window.Modals.userDefaults.hide();
}, 1000);
} else {
if ("error" in this.response) {
button.textContent = this.response["error"];
} else if (("policy" in this.response) || ("homescreen" in this.response)) {
button.textContent = "Failed (check console)";
} else {
button.textContent = "Failed";
}
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
}

View File

@ -1,87 +0,0 @@
import { rmAttr, addAttr } from "../modules/common.js";
interface aWindow extends Window {
rotateButton(el: HTMLElement): void;
}
declare var window: aWindow;
// Used for animation on theme change
const whichTransitionEvent = (): string => {
const el = document.createElement('fakeElement');
const transitions = {
'transition': 'transitionend',
'OTransition': 'oTransitionEnd',
'MozTransition': 'transitionend',
'WebkitTransition': 'webkitTransitionEnd'
};
for (const t in transitions) {
if (el.style[t] !== undefined) {
return transitions[t];
}
}
return '';
};
var transitionEndEvent = whichTransitionEvent();
// Toggles between light and dark themes
const _toggleCSS = (): void => {
const els: NodeListOf<HTMLLinkElement> = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]');
let cssEl = 0;
let remove = false;
if (els.length != 1) {
cssEl = 1;
remove = true
}
let href: string = "bs" + window.bsVersion;
if (!els[cssEl].href.includes(href + "-jf")) {
href += "-jf";
}
href += ".css";
let newEl = els[cssEl].cloneNode(true) as HTMLLinkElement;
newEl.href = href;
els[cssEl].parentNode.insertBefore(newEl, els[cssEl].nextSibling);
if (remove) {
els[0].remove();
}
document.cookie = "css=" + href;
}
// Toggles between light and dark themes, but runs animation if window small enough.
window.buttonWidth = 0;
export const toggleCSS = (el: HTMLElement): void => {
const switchToColor = window.getComputedStyle(document.body, null).backgroundColor;
// Max page width for animation to take place
let maxWidth = 1500;
if (window.innerWidth < maxWidth) {
// Calculate minimum radius to cover screen
const radius = Math.sqrt(Math.pow(window.innerWidth, 2) + Math.pow(window.innerHeight, 2));
const currentRadius = el.getBoundingClientRect().width / 2;
const scale = radius / currentRadius;
window.buttonWidth = +window.getComputedStyle(el, null).width;
document.body.classList.remove('smooth-transition');
el.style.transform = `scale(${scale})`;
el.style.color = switchToColor;
el.addEventListener(transitionEndEvent, function (): void {
if (this.style.transform.length != 0) {
_toggleCSS();
this.style.removeProperty('transform');
document.body.classList.add('smooth-transition');
}
}, false);
} else {
_toggleCSS();
el.style.color = switchToColor;
}
};
window.rotateButton = (el: HTMLElement): void => {
if (el.classList.contains("rotated")) {
rmAttr(el, "rotated")
addAttr(el, "not-rotated");
} else {
rmAttr(el, "not-rotated");
addAttr(el, "rotated");
}
};

View File

@ -1,45 +0,0 @@
declare var $: any;
class Modal implements BSModal {
el: HTMLDivElement;
modal: any;
constructor(id: string, find?: boolean) {
this.el = document.getElementById(id) as HTMLDivElement;
this.modal = $(this.el) as any;
this.modal.on("shown.b.modal", (): void => document.body.classList.add('modal-open'));
};
show(): void { this.modal.modal("show"); };
hide(): void { this.modal.modal("hide"); };
}
export class BS4 implements Bootstrap {
triggerTooltips: tooltipTrigger = function (): void {
const checkboxes = [].slice.call(document.getElementById('settingsContent').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return ($(el) as any).tooltip();
});
};
Compat(): void {
console.log('Fixing BS4 Compatability');
const send_to_address_enabled = document.getElementById('send_to_address_enabled');
if (send_to_address_enabled) {
send_to_address_enabled.classList.remove("form-check-input");
}
const multiUseEnabled = document.getElementById('multiUseEnabled');
if (multiUseEnabled) {
multiUseEnabled.classList.remove("form-check-input");
}
}
newModal: ModalConstructor = function (id: string, find?: boolean): BSModal {
return new Modal(id, find);
};
}

View File

@ -1,37 +0,0 @@
declare var bootstrap: any;
class Modal implements BSModal {
el: HTMLDivElement;
modal: any;
constructor(id: string, find?: boolean) {
this.el = document.getElementById(id) as HTMLDivElement;
if (find) {
this.modal = bootstrap.Modal.getInstance(this.el);
} else {
this.modal = new bootstrap.Modal(this.el);
}
this.el.addEventListener('shown.bs.modal', (): void => document.body.classList.add("modal-open"));
};
show(): void { this.modal.show(); };
hide(): void { this.modal.hide(); };
}
export class BS5 implements Bootstrap {
triggerTooltips: tooltipTrigger = function (): void {
const checkboxes = [].slice.call(document.getElementById('settingsContent').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return new bootstrap.Tooltip(el);
});
};
newModal: ModalConstructor = function (id: string, find?: boolean): BSModal {
return new Modal(id, find);
};
};

View File

@ -1,83 +0,0 @@
declare var window: Window;
export function createEl(html: string): HTMLElement {
let div = document.createElement('div') as HTMLDivElement;
div.innerHTML = html;
return div.firstElementChild as HTMLElement;
}
export function serializeForm(id: string): Object {
const form = document.getElementById(id) as HTMLFormElement;
let formData = {};
for (let i = 0; i < form.elements.length; i++) {
const el = form.elements[i];
if ((el as HTMLInputElement).type == "submit") {
continue;
}
let name = (el as HTMLInputElement).name;
if (!name) {
name = el.id;
}
switch ((el as HTMLInputElement).type) {
case "checkbox":
formData[name] = (el as HTMLInputElement).checked;
break;
case "text":
case "password":
case "email":
case "number":
formData[name] = (el as HTMLInputElement).value;
break;
case "select-one":
case "select":
let val: string = (el as HTMLSelectElement).value.toString();
if (!isNaN(val as any)) {
formData[name] = +val;
} else {
formData[name] = val;
}
break;
}
}
return formData;
}
export const rmAttr = (el: HTMLElement, attr: string): void => {
if (el.classList.contains(attr)) {
el.classList.remove(attr);
}
};
export const addAttr = (el: HTMLElement, attr: string): void => el.classList.add(attr);
export const _get = (url: string, data: Object, onreadystatechange: () => void): void => {
let req = new XMLHttpRequest();
req.open("GET", window.URLBase + url, true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.send(JSON.stringify(data));
};
export const _post = (url: string, data: Object, onreadystatechange: () => void, response?: boolean): void => {
let req = new XMLHttpRequest();
req.open("POST", window.URLBase + url, true);
if (response) {
req.responseType = 'json';
}
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.send(JSON.stringify(data));
};
export function _delete(url: string, data: Object, onreadystatechange: () => void): void {
let req = new XMLHttpRequest();
req.open("DELETE", window.URLBase + url, true);
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.send(JSON.stringify(data));
}

View File

@ -1,297 +0,0 @@
import { _get, _post, _delete } from "../modules/common.js";
interface aWindow extends Window {
setNotify(el: HTMLElement): void;
deleteInvite(code: string): void;
}
declare var window: aWindow;
const emptyInvite = (): Invite => { return { code: "None", empty: true } as Invite; }
function genUsedBy(usedBy: Array<Array<string>>): string {
let uB = "";
if (usedBy && usedBy.length != 0) {
uB = `
<ul class="list-group list-group-flush">
<li class="list-group-item py-1">Users created:</li>
`;
for (const i in usedBy) {
uB += `
<li class="list-group-item py-1 disabled">
<div class="d-flex float-left">${usedBy[i][0]}</div>
<div class="d-flex float-right">${usedBy[i][1]}</div>
</li>
`;
}
uB += `</ul>`
}
return uB;
}
function addItem(invite: Invite): void {
const links = document.getElementById('invites');
const container = document.createElement('div') as HTMLDivElement;
container.id = invite.code;
const item = document.createElement('div') as HTMLDivElement;
item.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
let link = "";
let innerHTML = `<a>None</a>`;
if (invite.empty) {
item.innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
${innerHTML}
</div>
`;
container.appendChild(item);
links.appendChild(container);
return;
}
link = window.location.href.split('#')[0] + "invite/" + invite.code;
innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
<a class="invite-link" href="${link}">${invite.code.replace(/-/g, '-')}</a>
<i class="fa fa-clipboard icon-button" onclick="window.toClipboard('${link}')" style="margin-right: 0.5rem; margin-left: 0.5rem;"></i>
`;
if (invite.email) {
let email = invite.email;
if (!invite.email.includes("Failed to send to")) {
email = `Sent to ${email}`;
}
innerHTML += `
<span class="text-muted" style="margin-left: 0.4rem; font-style: italic; font-size: 0.8rem;">${email}</span>
`;
}
innerHTML += `
</div>
<div style="text-align: right;">
<span id="${invite.code}_expiry" style="margin-right: 1rem;">${invite.expiresIn}</span>
<div style="display: inline-block;">
<button class="btn btn-outline-danger" onclick="deleteInvite('${invite.code}')">Delete</button>
<i class="fa fa-angle-down collapsed icon-button not-rotated" style="padding: 1rem; margin: -1rem -1rem -1rem 0;" data-toggle="collapse" aria-expanded="false" data-target="#${CSS.escape(invite.code)}_collapse" onclick="window.rotateButton(this)"></i>
</div>
</div>
`;
item.innerHTML = innerHTML;
container.appendChild(item);
let profiles = `
<label class="input-group-text" for="profile_${CSS.escape(invite.code)}">Profile: </label>
<select class="form-select" id="profile_${CSS.escape(invite.code)}" onchange="window.setProfile(this)">
<option value="NoProfile" selected>No Profile</option>
`;
for (const i in window.availableProfiles) {
let selected = "";
if (window.availableProfiles[i] == invite.profile) {
selected = "selected";
}
profiles += `<option value="${window.availableProfiles[i]}" ${selected}>${window.availableProfiles[i]}</option>`;
}
profiles += `</select>`;
let dateCreated: string;
if (invite.created) {
dateCreated = `<li class="list-group-item py-1">Created: ${invite.created}</li>`;
}
let middle: string;
if (window.notifications_enabled) {
middle = `
<div class="col" id="${CSS.escape(invite.code)}_notifyButtons">
<ul class="list-group list-group-flush">
Notify on:
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyExpiry" onclick="setNotify(this)" ${invite.notifyExpiry ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyExpiry">Expiry</label>
</li>
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyCreation" onclick="setNotify(this)" ${invite.notifyCreation ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyCreation">User creation</label>
</li>
</ul>
</div>
`;
}
let right: string = genUsedBy(invite.usedBy)
const dropdown = document.createElement('div') as HTMLDivElement;
dropdown.id = `${CSS.escape(invite.code)}_collapse`;
dropdown.classList.add("collapse");
dropdown.innerHTML = `
<div class="container row align-items-start card-body">
<div class="col">
<ul class="list-group list-group-flush">
<li class="input-group py-1">
${profiles}
</li>
${dateCreated}
<li class="list-group-item py-1" id="${CSS.escape(invite.code)}_remainingUses">Remaining uses: ${invite.remainingUses}</li>
</ul>
</div>
${middle}
<div class="col" id="${CSS.escape(invite.code)}_usersCreated">
${right}
</div>
</div>
`;
container.appendChild(dropdown);
links.appendChild(container);
}
function parseInvite(invite: Object): Invite {
let inv: Invite = { code: invite["code"], empty: false, };
if (invite["email"]) {
inv.email = invite["email"];
}
let time = ""
const f = ["days", "hours", "minutes"];
for (const i in f) {
if (invite[f[i]] != 0) {
time += `${invite[f[i]]}${f[i][0]} `;
}
}
inv.expiresIn = `Expires in ${time.slice(0, -1)}`;
if (invite["no-limit"]) {
inv.remainingUses = "∞";
} else if ("remaining-uses" in invite) {
inv.remainingUses = invite["remaining-uses"];
}
if ("used-by" in invite) {
inv.usedBy = invite["used-by"];
}
if ("created" in invite) {
inv.created = invite["created"];
}
if ("notify-expiry" in invite) {
inv.notifyExpiry = invite["notify-expiry"];
}
if ("notify-creation" in invite) {
inv.notifyCreation = invite["notify-creation"];
}
if ("profile" in invite) {
inv.profile = invite["profile"];
}
return inv;
}
window.setNotify = (el: HTMLElement): void => {
let send = {};
let code: string;
let notifyType: string;
if (el.id.includes("Expiry")) {
code = el.id.replace("_notifyExpiry", "");
notifyType = "notify-expiry";
} else if (el.id.includes("Creation")) {
code = el.id.replace("_notifyCreation", "");
notifyType = "notify-creation";
}
send[code] = {};
send[code][notifyType] = (el as HTMLInputElement).checked;
_post("/invites/notify", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
(el as HTMLInputElement).checked = !(el as HTMLInputElement).checked;
}
});
}
function updateInvite(invite: Invite): void {
document.getElementById(invite.code + "_expiry").textContent = invite.expiresIn;
const remainingUses: any = document.getElementById(CSS.escape(invite.code) + "_remainingUses");
if (remainingUses) {
remainingUses.textContent = `Remaining uses: ${invite.remainingUses}`;
}
document.getElementById(CSS.escape(invite.code) + "_usersCreated").innerHTML = genUsedBy(invite.usedBy);
}
// delete invite from DOM
const hideInvite = (code: string): void => document.getElementById(CSS.escape(code)).remove();
// delete invite from jfa-go
window.deleteInvite = (code: string): void => _delete("/invites", { "code": code }, function (): void {
if (this.readyState == 4) {
generateInvites();
}
});
export function generateInvites(empty?: boolean): void {
if (empty) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
return;
}
_get("/invites", null, function (): void {
if (this.readyState == 4) {
let data = this.response;
window.availableProfiles = data['profiles'];
const Profiles = document.getElementById('inviteProfile') as HTMLSelectElement;
let innerHTML = "";
for (let i = 0; i < window.availableProfiles.length; i++) {
const profile = window.availableProfiles[i];
innerHTML += `
<option value="${profile}" ${(i == 0) ? "selected" : ""}>${profile}</option>
`;
}
innerHTML += `
<option value="NoProfile" ${(window.availableProfiles.length == 0) ? "selected" : ""}>No Profile</option>
`;
Profiles.innerHTML = innerHTML;
if (data['invites'] == null || data['invites'].length == 0) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
return;
}
let items = document.getElementById('invites').children;
for (const i in data['invites']) {
let match = false;
const inv = parseInvite(data['invites'][i]);
for (const x in items) {
if (items[x].id == inv.code) {
match = true;
updateInvite(inv);
break;
}
}
if (!match) {
addItem(inv);
}
}
// second pass to check for expired invites
items = document.getElementById('invites').children;
for (let i = 0; i < items.length; i++) {
let exists = false;
for (const x in data['invites']) {
if (items[i].id == data['invites'][x]['code']) {
exists = true;
break;
}
}
if (!exists) {
hideInvite(items[i].id);
}
}
}
});
}
export const addOptions = (length: number, el: HTMLSelectElement): void => {
for (let v = 0; v <= length; v++) {
const opt = document.createElement('option');
opt.textContent = ""+v;
opt.value = ""+v;
el.appendChild(opt);
}
el.value = "0";
};
export function checkDuration(): void {
const boxVals: Array<number> = [+(document.getElementById("days") as HTMLSelectElement).value, +(document.getElementById("hours") as HTMLSelectElement).value, +(document.getElementById("minutes") as HTMLSelectElement).value];
const submit = document.getElementById('generateSubmit') as HTMLButtonElement;
if (boxVals.reduce((a: number, b: number): number => a + b) == 0) {
submit.disabled = true;
} else {
submit.disabled = false;
}
}

View File

@ -1,164 +0,0 @@
import { _get, _post, _delete, rmAttr, addAttr } from "../modules/common.js";
import { Focus, Unfocus } from "../modules/admin.js";
interface Profile {
Admin: boolean;
LibraryAccess: string;
FromUser: string;
}
export const populateProfiles = (noTable?: boolean): void => _get("/profiles", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
const profileList = document.getElementById('profileList');
profileList.textContent = '';
window.availableProfiles = [this.response["default_profile"]];
for (let name in this.response["profiles"]) {
if (name != window.availableProfiles[0]) {
window.availableProfiles.push(name);
}
const reqProfile = this.response["profiles"][name];
if (!noTable && name != "default_profile") {
const profile: Profile = {
Admin: reqProfile["admin"],
LibraryAccess: reqProfile["libraries"],
FromUser: reqProfile["fromUser"]
};
profileList.innerHTML += `
<td nowrap="nowrap" class="align-middle"><strong>${name}</strong></td>
<td nowrap="nowrap" class="align-middle"><input class="${window.bs5 ? "form-check-input" : ""}" type="radio" name="defaultProfile" onclick="setDefaultProfile('${name}')" ${(name == window.availableProfiles[0]) ? "checked" : ""}></td>
<td nowrap="nowrap" class="align-middle">${profile.FromUser}</td>
<td nowrap="nowrap" class="align-middle">${profile.Admin ? "Yes" : "No"}</td>
<td nowrap="nowrap" class="align-middle">${profile.LibraryAccess}</td>
<td nowrap="nowrap" class="align-middle"><button class="btn btn-outline-danger" id="defaultProfile_${name}" onclick="deleteProfile('${name}')">Delete</button></td>
`;
}
}
}
});
export const openSettings = (settingsList: HTMLElement, settingsContent: HTMLElement, callback?: () => void): void => _get("/config", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
settingsList.textContent = '';
window.config = this.response;
for (const i in window.config["order"]) {
const section: string = window.config["order"][i]
const sectionCollapse = document.createElement('div') as HTMLDivElement;
Unfocus(sectionCollapse);
sectionCollapse.id = section;
const title: string = window.config[section]["meta"]["name"];
const description: string = window.config[section]["meta"]["description"];
const entryListID: string = `${section}_entryList`;
// const footerID: string = `${section}_footer`;
sectionCollapse.innerHTML = `
<div class="card card-body">
<small class="text-muted">${description}</small>
<div class="${entryListID}">
</div>
</div>
`;
for (const x in config[section]["order"]) {
const entry: string = config[section]["order"][x];
if (entry == "meta") {
continue;
}
let entryName: string = window.config[section][entry]["name"];
let required = false;
if (window.config[section][entry]["required"]) {
entryName += ` <sup class="text-danger">*</sup>`;
required = true;
}
if (window.config[section][entry]["requires_restart"]) {
entryName += ` <sup class="text-danger">R</sup>`;
}
if ("description" in window.config[section][entry]) {
entryName +=`
<a class="text-muted" href="#" data-toggle="tooltip" data-placement="right" title="${window.config[section][entry]['description']}"><i class="fa fa-question-circle-o"></i></a>
`;
}
const entryValue: boolean | string = window.config[section][entry]["value"];
const entryType: string = window.config[section][entry]["type"];
const entryGroup = document.createElement('div');
if (entryType == "bool") {
entryGroup.classList.add("form-check");
entryGroup.innerHTML = `
<input class="form-check-input" type="checkbox" value="" id="${section}_${entry}" ${(entryValue as boolean) ? 'checked': ''} ${required ? 'required' : ''}>
<label class="form-check-label" for="${section}_${entry}">${entryName}</label>
`;
(entryGroup.querySelector('input[type=checkbox]') as HTMLInputElement).onclick = function (): void {
const me = this as HTMLInputElement;
for (const y in window.config["order"]) {
const sect: string = window.config["order"][y];
for (const z in window.config[sect]["order"]) {
const ent: string = window.config[sect]["order"][z];
if (`${sect}_${window.config[sect][ent]['depends_true']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = !(me.checked);
} else if (`${sect}_${window.config[sect][ent]['depends_false']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = me.checked;
}
}
}
};
} else if ((entryType == 'text') || (entryType == 'email') || (entryType == 'password') || (entryType == 'number')) {
entryGroup.classList.add("form-group");
entryGroup.innerHTML = `
<label for="${section}_${entry}">${entryName}</label>
<input type="${entryType}" class="form-control" id="${section}_${entry}" aria-describedby="${entry}" value="${entryValue}" ${required ? 'required' : ''}>
`;
} else if (entryType == 'select') {
entryGroup.classList.add("form-group");
const entryOptions: Array<string> = window.config[section][entry]["options"];
let innerGroup = `
<label for="${section}_${entry}">${entryName}</label>
<select class="form-control" id="${section}_${entry}" ${required ? 'required' : ''}>
`;
for (const z in entryOptions) {
const entryOption = entryOptions[z];
let selected: boolean = (entryOption == entryValue);
innerGroup += `
<option value="${entryOption}" ${selected ? 'selected' : ''}>${entryOption}</option>
`;
}
innerGroup += `</select>`;
entryGroup.innerHTML = innerGroup;
}
sectionCollapse.getElementsByClassName(entryListID)[0].appendChild(entryGroup);
}
settingsList.innerHTML += `
<button type="button" class="list-group-item list-group-item-action" id="${section}_button" onclick="showSetting('${section}')">${title}</button>
`;
settingsContent.appendChild(sectionCollapse);
}
if (callback) {
callback();
}
}
});
export function showSetting(id: string, runBefore?: () => void): void {
const els = document.getElementById('settingsLeft').querySelectorAll("button[type=button]:not(.static)") as NodeListOf<HTMLButtonElement>;
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (el.id != `${id}_button`) {
rmAttr(el, "active");
}
const sectEl = document.getElementById(el.id.replace("_button", ""));
if (sectEl.id != id) {
Unfocus(sectEl);
}
}
addAttr(document.getElementById(`${id}_button`), "active");
const section = document.getElementById(id);
if (runBefore) {
runBefore();
}
Focus(section);
if (screen.width <= 1100) {
// ugly
setTimeout((): void => section.scrollIntoView(<ScrollIntoViewOptions>{ block: "center", behavior: "smooth" }), 200);
}
}

View File

@ -1,84 +0,0 @@
import { _get, _post, _delete, rmAttr, addAttr } from "./modules/common.js";
const ombiDefaultsModal = window.BS.newModal('ombiDefaults');
(document.getElementById('openOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this as HTMLButtonElement;
button.disabled = true;
const ogHTML = button.innerHTML;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
_get("/ombi/users", null, function (): void {
if (this.readyState == 4) {
if (this.status == 200) {
const users = this.response['users'];
const radioList = document.getElementById('ombiUserRadios');
radioList.textContent = '';
let first = true;
for (const i in users) {
const user = users[i];
const radio = document.createElement('div') as HTMLDivElement;
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="ombiRadios" id="ombiDefault_${user['id']}" ${checked}>
<label class="form-check-label" for="ombiDefault_${user['id']}">${user['name']}</label>
`;
radioList.appendChild(radio);
}
button.disabled = false;
button.innerHTML = ogHTML;
const submitButton = document.getElementById('storeOmbiDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Submit';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-success");
rmAttr(submitButton, "btn-danger");
ombiDefaultsModal.show();
}
}
});
};
(document.getElementById('storeOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this 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...';
const radio = document.querySelector('input[name=ombiRadios]:checked') as HTMLInputElement;
const data = {
"id": radio.id.replace("ombiDefault_", "")
};
_post("/ombi/defaults", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => ombiDefaultsModal.hide(), 1000);
} else {
button.textContent = "Failed";
rmAttr(button, "btn-primary");
addAttr(button, "btn-danger");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
};

View File

@ -1,205 +0,0 @@
import { _post, _get, _delete, rmAttr, addAttr } from "./modules/common.js";
import { generateInvites } from "./modules/invites.js";
import { populateRadios } from "./modules/accounts.js";
import { Focus, Unfocus } from "./modules/admin.js";
import { showSetting, populateProfiles } from "./modules/settings.js";
interface aWindow extends Window {
setDefaultProfile(name: string): void;
deleteProfile(name: string): void;
createProfile(): void;
showSetting(id: string, runBefore?: () => void): void;
config: Object;
modifiedConfig: Object;
}
declare var window: aWindow;
window.config = {};
window.modifiedConfig = {};
window.showSetting = showSetting;
function sendConfig(restart?: boolean): void {
window.modifiedConfig["restart-program"] = restart;
_post("/config", window.modifiedConfig, function (): void {
if (this.readyState == 4) {
const save = document.getElementById("settingsSave") as HTMLButtonElement
if (this.status == 200 || this.status == 204) {
save.textContent = "Success";
addAttr(save, "btn-success");
rmAttr(save, "btn-primary");
setTimeout((): void => {
save.textContent = "Save";
addAttr(save, "btn-primary");
rmAttr(save, "btn-success");
}, 1000);
} else {
save.textContent = "Save";
}
if (restart) {
window.Modals.refresh.show();
}
}
});
}
(document.getElementById('openAbout') as HTMLButtonElement).onclick = (): void => {
window.Modals.about.show();
};
(document.getElementById('profiles_button') as HTMLButtonElement).onclick = (): void => showSetting("profiles", populateProfiles);
window.setDefaultProfile = (name: string): void => _post("/profiles/default", { "name": name }, function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
(document.getElementById(`defaultProfile_${window.availableProfiles[0]}`) as HTMLInputElement).checked = true;
(document.getElementById(`defaultProfile_${name}`) as HTMLInputElement).checked = false;
} else {
generateInvites();
}
}
});
window.deleteProfile = (name: string): void => _delete("/profiles", { "name": name }, function (): void {
if (this.readyState == 4 && this.status == 200) {
populateProfiles();
}
});
const createProfile = (): void => _get("/users", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
window.jfUsers = this.response["users"];
populateRadios();
const submitButton = document.getElementById('storeDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Create';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-danger");
rmAttr(submitButton, "btn-success");
document.getElementById('defaultsTitle').textContent = `Create Profile`;
document.getElementById('userDefaultsDescription').textContent = `
Create an account and configure it to your liking, then choose it from below to store the settings as a profile. Profiles can be specified per invite, so that any new user on that invite will have the settings applied.`;
document.getElementById('storeHomescreenLabel').textContent = `Store homescreen layout`;
(document.getElementById('defaultsSource') as HTMLSelectElement).value = 'fromUser';
document.getElementById('defaultsSourceSection').classList.add('unfocused');
(document.getElementById('storeDefaults') as HTMLButtonElement).onclick = storeProfile;
Focus(document.getElementById('newProfileBox'));
(document.getElementById('newProfileName') as HTMLInputElement).value = '';
Focus(document.getElementById('defaultUserRadiosBox'));
window.Modals.userDefaults.show();
}
});
window.createProfile = createProfile;
function storeProfile(): void {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
const button = document.getElementById('storeDefaults') as HTMLButtonElement;
const radio = document.querySelector('input[name=defaultRadios]:checked') as HTMLInputElement
const name = (document.getElementById('newProfileName') as HTMLInputElement).value;
let id = radio.id.replace("default_", "");
let data = {
"name": name,
"id": id,
"homescreen": false
}
if ((document.getElementById('storeDefaultHomescreen') as HTMLInputElement).checked) {
data["homescreen"] = true;
}
_post("/profiles", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => {
button.textContent = "Create";
addAttr(button, "btn-primary");
rmAttr(button, "btn-success");
button.disabled = false;
window.Modals.userDefaults.hide();
}, 1000);
populateProfiles();
generateInvites();
} else {
if ("error" in this.response) {
button.textContent = this.response["error"];
} else if (("policy" in this.response) || ("homescreen" in this.response)) {
button.textContent = "Failed (check console)";
} else {
button.textContent = "Failed";
}
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
button.textContent = "Create";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
}
// (document.getElementById('openSettings') as HTMLButtonElement).onclick = (): void => openSettings(document.getElementById('settingsList'), document.getElementById('settingsList'), (): void => settingsModal.show());
(document.getElementById('settingsSave') as HTMLButtonElement).onclick = function (): void {
window.modifiedConfig = {};
const save = this as HTMLButtonElement;
let restartSettingsChanged = false;
let settingsChanged = false;
for (const i in window.config["order"]) {
const section = window.config["order"][i];
for (const x in window.config[section]["order"]) {
const entry = window.config[section]["order"][x];
if (entry == "meta") {
continue;
}
let val: string;
const entryID = `${section}_${entry}`;
const el = document.getElementById(entryID) as HTMLInputElement;
if (el.type == "checkbox") {
val = el.checked.toString();
} else {
val = el.value.toString();
}
if (val != window.config[section][entry]["value"].toString()) {
if (!(section in window.modifiedConfig)) {
window.modifiedConfig[section] = {};
}
window.modifiedConfig[section][entry] = val;
settingsChanged = true;
if (window.config[section][entry]["requires_restart"]) {
restartSettingsChanged = true;
}
}
}
}
const spinnerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
if (restartSettingsChanged) {
save.innerHTML = spinnerHTML;
(document.getElementById('applyRestarts') as HTMLButtonElement).onclick = (): void => sendConfig();
const restartButton = document.getElementById('applyAndRestart') as HTMLButtonElement;
if (restartButton) {
restartButton.onclick = (): void => sendConfig(true);
}
window.Modals.restart.show();
} else if (settingsChanged) {
save.innerHTML = spinnerHTML;
sendConfig();
}
};
(document.getElementById('restartModalCancel') as HTMLButtonElement).onclick = (): void => {
document.getElementById('settingsSave').textContent = "Save";
};

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