From 95db48d8f8b903df9103251b1482e1172b53545e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 29 Dec 2020 18:42:26 +0000 Subject: [PATCH] start main app integration, restructure build process the backend code has been added back in, and the build process has been modified so that no output files are left outside of build/. Admin page loads and logs in correctly, nothing else functionality-wise yet. --- .gitignore | 19 - LICENSE | 21 + Makefile | 61 ++ README.md | 5 +- api.go | 1264 +++++++++++++++++++++++++ auth.go | 228 +++++ common/common.go | 23 + common/go.mod | 3 + config.go | 86 ++ config/README.md | 11 + config/config-base.json | 668 ++++++++++++++ config/configStruct.go | 541 +++++++++++ config/fixconfig.py | 27 + config/generate_ini.py | 42 + config/jsontostruct.py | 57 ++ css/base.css | 4 +- daemon.go | 50 + docs/go.mod | 8 + email.go | 317 +++++++ go.mod | 54 ++ go.sum | 431 +++++++++ html/404.html | 21 + admin.html => html/admin.html | 12 +- html/form-base.html | 10 + html/form-loader.html | 1 + form.html => html/form.html | 14 +- html/header.html | 11 + html/invalidCode.html | 20 + html/setup.html | 374 ++++++++ jfapi/go.mod | 7 + jfapi/jfapi.go | 370 ++++++++ lang/form/en-us.json | 41 + lang/form/fr-fr.json | 42 + mail/created.mjml | 47 + mail/created.txt | 7 + mail/deleted.mjml | 36 + mail/deleted.txt | 4 + mail/email.mjml | 40 + mail/email.txt | 10 + mail/expired.mjml | 36 + mail/expired.txt | 5 + mail/generate.py | 38 + mail/invite-email.mjml | 39 + mail/invite-email.txt | 8 + main.go | 745 +++++++++++++++ models.go | 129 +++ ombi/go.mod | 5 + ombi/ombi.go | 221 +++++ package-lock.json | 1429 ++++++++++++++++++++++++++++- package.json | 16 +- pwreset.go | 105 +++ pwval.go | 70 ++ setup.go | 26 + static/android-chrome-192x192.png | Bin 0 -> 6043 bytes static/android-chrome-512x512.png | Bin 0 -> 40781 bytes static/apple-touch-icon.png | Bin 0 -> 3585 bytes static/banner.svg | 283 ++++++ static/browserconfig.xml | 9 + static/favicon-16x16.png | Bin 0 -> 1034 bytes static/favicon-32x32.png | Bin 0 -> 1729 bytes static/favicon.ico | Bin 0 -> 15086 bytes static/mstile-150x150.png | Bin 0 -> 4811 bytes static/safari-pinned-tab.svg | 1 + static/site.webmanifest | 19 + storage.go | 236 +++++ ts/admin.ts | 189 +++- ts/modules/tabs.ts | 34 + ts/modules/{theme.js => theme.ts} | 0 ts/typings/d.ts | 16 + version.py | 27 + views.go | 68 ++ 71 files changed, 8619 insertions(+), 122 deletions(-) create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 api.go create mode 100644 auth.go create mode 100644 common/common.go create mode 100644 common/go.mod create mode 100644 config.go create mode 100644 config/README.md create mode 100644 config/config-base.json create mode 100644 config/configStruct.go create mode 100644 config/fixconfig.py create mode 100644 config/generate_ini.py create mode 100644 config/jsontostruct.py create mode 100644 daemon.go create mode 100644 docs/go.mod create mode 100644 email.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 html/404.html rename admin.html => html/admin.html (98%) create mode 100644 html/form-base.html create mode 100644 html/form-loader.html rename form.html => html/form.html (84%) create mode 100644 html/header.html create mode 100644 html/invalidCode.html create mode 100644 html/setup.html create mode 100644 jfapi/go.mod create mode 100644 jfapi/jfapi.go create mode 100644 lang/form/en-us.json create mode 100644 lang/form/fr-fr.json create mode 100644 mail/created.mjml create mode 100644 mail/created.txt create mode 100644 mail/deleted.mjml create mode 100644 mail/deleted.txt create mode 100644 mail/email.mjml create mode 100644 mail/email.txt create mode 100644 mail/expired.mjml create mode 100644 mail/expired.txt create mode 100755 mail/generate.py create mode 100644 mail/invite-email.mjml create mode 100644 mail/invite-email.txt create mode 100644 main.go create mode 100644 models.go create mode 100644 ombi/go.mod create mode 100644 ombi/ombi.go create mode 100644 pwreset.go create mode 100644 pwval.go create mode 100644 setup.go create mode 100644 static/android-chrome-192x192.png create mode 100644 static/android-chrome-512x512.png create mode 100644 static/apple-touch-icon.png create mode 100644 static/banner.svg create mode 100644 static/browserconfig.xml create mode 100644 static/favicon-16x16.png create mode 100644 static/favicon-32x32.png create mode 100644 static/favicon.ico create mode 100644 static/mstile-150x150.png create mode 100644 static/safari-pinned-tab.svg create mode 100644 static/site.webmanifest create mode 100644 storage.go create mode 100644 ts/modules/tabs.ts rename ts/modules/{theme.js => theme.ts} (100%) create mode 100644 version.py create mode 100644 views.go diff --git a/.gitignore b/.gitignore index d7fb509..a549da2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,8 @@ node_modules/ -passwordreset*.json mail/*.html -scss/*.css* -scss/bs4/*.css* -scss/bs5/*.css* -data/static/*.css -data/static/*.js -data/static/*.js.map -data/static/ts/ -data/static/modules/ -!data/static/setup.js -data/config-base.json -data/config-default.ini -data/*.html -data/*.txt -data/docs/ dist/* -jfa-go build/ -pkg/ -old/ version.go notes docs/* !docs/go.mod -js/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc91e2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ae9317 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +configuration: + $(info Fixing config-base) + -mkdir -p build/data + python3 config/fixconfig.py -i config/config-base.json -o build/data/config-base.json + $(info Generating config-default.ini) + python3 config/generate_ini.py -i config/config-base.json -o build/data/config-default.ini + +email: + $(info Generating email html) + python3 mail/generate.py + +ts: + $(info compiling typescript) + -mkdir -p build/data/web/js + -npx esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./build/data/web/js/ + +ts-debug: + $(info compiling typescript w/ sourcemaps) + -mkdir -p build/data/web/js + -npx esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./build/data/web/js/ + -rm -r build/data/web/js/ts + $(info copying typescript) + cp -r ts build/data/web/js + +swagger: + go get github.com/swaggo/swag/cmd/swag + 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 css) + -mkdir -p build/data/web/css + cp -r css build/data/web/ + cp node_modules/a17t/dist/a17t.css build/data/web/css/ + cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 build/data/web/css/ + $(info copying html) + cp -r html build/data/ + $(info copying static data) + -mkdir -p build/data/web + cp -r static/* build/data/web/ + $(info copying language files) + cp -r lang build/data/ + + +install: + cp -r build $(DESTDIR)/jfa-go + +all: configuration email version ts swagger compile copy +debug: configuration email version ts-debug swagger compile copy diff --git a/README.md b/README.md index 32756ed..c27a77e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -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. +This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code. #### todo **general** @@ -15,7 +15,8 @@ This branch is for experimenting with [a17t](https://a17t.miles.land/) to possib * [ ] integration with existing code **invites** -* [ ] everything +* [x] page design +* [ ] integration with existing code #### screenshots ##### dark diff --git a/api.go b/api.go new file mode 100644 index 0000000..e01920f --- /dev/null +++ b/api.go @@ -0,0 +1,1264 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/knz/strtime" + "github.com/lithammer/shortuuid/v3" + "gopkg.in/ini.v1" +) + +func respond(code int, message string, gc *gin.Context) { + resp := stringResponse{} + if code == 200 || code == 204 { + resp.Response = message + } else { + resp.Error = message + } + gc.JSON(code, resp) + gc.Abort() +} + +func respondBool(code int, val bool, gc *gin.Context) { + resp := boolResponse{} + if !val { + resp.Error = true + } else { + resp.Success = true + } + gc.JSON(code, resp) + gc.Abort() +} + +func (app *appContext) loadStrftime() { + app.datePattern = app.config.Section("email").Key("date_format").String() + app.timePattern = `%H:%M` + if val, _ := app.config.Section("email").Key("use_24h").Bool(); !val { + app.timePattern = `%I:%M %p` + } + return +} + +func (app *appContext) prettyTime(dt time.Time) (date, time string) { + date, _ = strtime.Strftime(dt, app.datePattern) + time, _ = strtime.Strftime(dt, app.timePattern) + return +} + +func (app *appContext) formatDatetime(dt time.Time) string { + d, t := app.prettyTime(dt) + return d + " " + t +} + +// https://stackoverflow.com/questions/36530251/time-since-with-months-and-years/36531443#36531443 THANKS +func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) { + if a.Location() != b.Location() { + b = b.In(a.Location()) + } + if a.After(b) { + a, b = b, a + } + y1, M1, d1 := a.Date() + y2, M2, d2 := b.Date() + + h1, m1, s1 := a.Clock() + h2, m2, s2 := b.Clock() + + year = int(y2 - y1) + month = int(M2 - M1) + day = int(d2 - d1) + hour = int(h2 - h1) + min = int(m2 - m1) + sec = int(s2 - s1) + + // Normalize negative values + if sec < 0 { + sec += 60 + min-- + } + if min < 0 { + min += 60 + hour-- + } + if hour < 0 { + hour += 24 + day-- + } + if day < 0 { + // days in month: + t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC) + day += 32 - t.Day() + month-- + } + if month < 0 { + month += 12 + year-- + } + return +} + +func (app *appContext) checkInvites() { + currentTime := time.Now() + app.storage.loadInvites() + changed := false + for code, data := range app.storage.invites { + expiry := data.ValidTill + if !currentTime.After(expiry) { + continue + } + app.debug.Printf("Housekeeping: Deleting old invite %s", code) + notify := data.Notify + if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 { + app.debug.Printf("%s: Expiry notification", code) + for address, settings := range notify { + if !settings["notify-expiry"] { + continue + } + go func() { + msg, err := app.email.constructExpiry(code, data, app) + if err != nil { + app.err.Printf("%s: Failed to construct expiry notification", code) + app.debug.Printf("Error: %s", err) + } else if err := app.email.send(address, msg); err != nil { + app.err.Printf("%s: Failed to send expiry notification", code) + app.debug.Printf("Error: %s", err) + } else { + app.info.Printf("Sent expiry notification to %s", address) + } + }() + } + } + changed = true + delete(app.storage.invites, code) + } + if changed { + app.storage.storeInvites() + } +} + +func (app *appContext) checkInvite(code string, used bool, username string) bool { + currentTime := time.Now() + app.storage.loadInvites() + changed := false + inv, match := app.storage.invites[code] + if !match { + return false + } + expiry := inv.ValidTill + if currentTime.After(expiry) { + app.debug.Printf("Housekeeping: Deleting old invite %s", code) + notify := inv.Notify + if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 { + app.debug.Printf("%s: Expiry notification", code) + for address, settings := range notify { + if settings["notify-expiry"] { + go func() { + msg, err := app.email.constructExpiry(code, inv, app) + if err != nil { + app.err.Printf("%s: Failed to construct expiry notification", code) + app.debug.Printf("Error: %s", err) + } else if err := app.email.send(address, msg); err != nil { + app.err.Printf("%s: Failed to send expiry notification", code) + app.debug.Printf("Error: %s", err) + } else { + app.info.Printf("Sent expiry notification to %s", address) + } + }() + } + } + } + changed = true + match = false + delete(app.storage.invites, code) + } else if used { + changed = true + del := false + newInv := inv + if newInv.RemainingUses == 1 { + del = true + delete(app.storage.invites, code) + } else if newInv.RemainingUses != 0 { + // 0 means infinite i guess? + newInv.RemainingUses -= 1 + } + newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)}) + if !del { + app.storage.invites[code] = newInv + } + } + if changed { + app.storage.storeInvites() + } + return match +} + +func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) { + ombiUsers, code, err := app.ombi.GetUsers() + if err != nil || code != 200 { + return nil, code, err + } + jfUser, code, err := app.jf.UserByID(jfID, false) + if err != nil || code != 200 { + return nil, code, err + } + username := jfUser["Name"].(string) + email := "" + if e, ok := app.storage.emails[jfID]; ok { + email = e.(string) + } + for _, ombiUser := range ombiUsers { + ombiAddr := "" + if a, ok := ombiUser["emailAddress"]; ok && a != nil { + ombiAddr = a.(string) + } + if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") { + return ombiUser, code, err + } + } + return nil, 400, fmt.Errorf("Couldn't find user") +} + +// Routes from now on! + +// @Summary Creates a new Jellyfin user without an invite. +// @Produce json +// @Param newUserDTO body newUserDTO true "New user request object" +// @Success 200 +// @Router /users [post] +// @Security Bearer +// @tags Users +func (app *appContext) NewUserAdmin(gc *gin.Context) { + var req newUserDTO + gc.BindJSON(&req) + existingUser, _, _ := app.jf.UserByName(req.Username, false) + if existingUser != nil { + msg := fmt.Sprintf("User already exists named %s", req.Username) + app.info.Printf("%s New user failed: %s", req.Username, msg) + respond(401, msg, gc) + return + } + user, status, err := app.jf.NewUser(req.Username, req.Password) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Username, status) + respond(401, "Unknown error", gc) + return + } + var id string + if user["Id"] != nil { + id = user["Id"].(string) + } + if len(app.storage.policy) != 0 { + status, err = app.jf.SetPolicy(id, app.storage.policy) + if !(status == 200 || status == 204 || err == nil) { + app.err.Printf("%s: Failed to set user policy: Code %d", req.Username, status) + app.debug.Printf("%s: Error: %s", req.Username, err) + } + } + if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 { + status, err = app.jf.SetConfiguration(id, app.storage.configuration) + if (status == 200 || status == 204) && err == nil { + status, err = app.jf.SetDisplayPreferences(id, app.storage.displayprefs) + } + if !((status == 200 || status == 204) && err == nil) { + app.err.Printf("%s: Failed to set configuration template: Code %d", req.Username, status) + } + } + if app.config.Section("password_resets").Key("enabled").MustBool(false) { + app.storage.emails[id] = req.Email + app.storage.storeEmails() + } + if app.config.Section("ombi").Key("enabled").MustBool(false) { + app.storage.loadOmbiTemplate() + if len(app.storage.ombi_template) != 0 { + errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, app.storage.ombi_template) + if err != nil || code != 200 { + app.info.Printf("Failed to create Ombi user (%d): %s", code, err) + app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) + } else { + app.info.Println("Created Ombi user") + } + } + } + app.jf.CacheExpiry = time.Now() +} + +// @Summary Creates a new Jellyfin user via invite code +// @Produce json +// @Param newUserDTO body newUserDTO true "New user request object" +// @Success 200 {object} PasswordValidation +// @Failure 400 {object} PasswordValidation +// @Router /newUser [post] +// @tags Users +func (app *appContext) NewUser(gc *gin.Context) { + var req newUserDTO + gc.BindJSON(&req) + app.debug.Printf("%s: New user attempt", req.Code) + if !app.checkInvite(req.Code, false, "") { + app.info.Printf("%s New user failed: invalid code", req.Code) + respondBool(401, false, gc) + return + } + validation := app.validator.validate(req.Password) + valid := true + for _, val := range validation { + if !val { + valid = false + } + } + if !valid { + // 200 bcs idk what i did in js + app.info.Printf("%s New user failed: Invalid password", req.Code) + gc.JSON(200, validation) + gc.Abort() + return + } + existingUser, _, _ := app.jf.UserByName(req.Username, false) + if existingUser != nil { + msg := fmt.Sprintf("User already exists named %s", req.Username) + app.info.Printf("%s New user failed: %s", req.Code, msg) + respond(401, msg, gc) + return + } + user, status, err := app.jf.NewUser(req.Username, req.Password) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status) + respond(401, "Unknown error", gc) + return + } + app.storage.loadProfiles() + invite := app.storage.invites[req.Code] + app.checkInvite(req.Code, true, req.Username) + if app.config.Section("notifications").Key("enabled").MustBool(false) { + for address, settings := range invite.Notify { + if settings["notify-creation"] { + go func() { + msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app) + if err != nil { + app.err.Printf("%s: Failed to construct user creation notification", req.Code) + app.debug.Printf("%s: Error: %s", req.Code, err) + } else if err := app.email.send(address, msg); err != nil { + app.err.Printf("%s: Failed to send user creation notification", req.Code) + app.debug.Printf("%s: Error: %s", req.Code, err) + } else { + app.info.Printf("%s: Sent user creation notification to %s", req.Code, address) + } + }() + } + } + } + var id string + if user["Id"] != nil { + id = user["Id"].(string) + } + if invite.Profile != "" { + app.debug.Printf("Applying settings from profile \"%s\"", invite.Profile) + profile, ok := app.storage.profiles[invite.Profile] + if !ok { + profile = app.storage.profiles["Default"] + } + if len(profile.Policy) != 0 { + app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile) + status, err = app.jf.SetPolicy(id, profile.Policy) + if !((status == 200 || status == 204) && err == nil) { + app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status) + app.debug.Printf("%s: Error: %s", req.Code, err) + } + } + if len(profile.Configuration) != 0 && len(profile.Displayprefs) != 0 { + app.debug.Printf("Applying homescreen from profile \"%s\"", invite.Profile) + status, err = app.jf.SetConfiguration(id, profile.Configuration) + if (status == 200 || status == 204) && err == nil { + status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs) + } + if !((status == 200 || status == 204) && err == nil) { + app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status) + app.debug.Printf("%s: Error: %s", req.Code, err) + } + } + } + // if app.config.Section("password_resets").Key("enabled").MustBool(false) { + if req.Email != "" { + app.storage.emails[id] = req.Email + app.storage.storeEmails() + } + if app.config.Section("ombi").Key("enabled").MustBool(false) { + app.storage.loadOmbiTemplate() + if len(app.storage.ombi_template) != 0 { + errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, app.storage.ombi_template) + if err != nil || code != 200 { + app.info.Printf("Failed to create Ombi user (%d): %s", code, err) + app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) + } else { + app.info.Println("Created Ombi user") + } + } + } + code := 200 + for _, val := range validation { + if !val { + code = 400 + } + } + gc.JSON(code, validation) +} + +// @Summary Delete a list of users, optionally notifying them why. +// @Produce json +// @Param deleteUserDTO body deleteUserDTO true "User deletion request object" +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 500 {object} errorListDTO "List of errors" +// @Router /users [delete] +// @Security Bearer +// @tags Users +func (app *appContext) DeleteUser(gc *gin.Context) { + var req deleteUserDTO + gc.BindJSON(&req) + errors := map[string]string{} + ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + for _, userID := range req.Users { + if ombiEnabled { + ombiUser, code, err := app.getOmbiUser(userID) + if code == 200 && err == nil { + if id, ok := ombiUser["id"]; ok { + status, err := app.ombi.DeleteUser(id.(string)) + if err != nil || status != 200 { + app.err.Printf("Failed to delete ombi user: %d %s", status, err) + errors[userID] = fmt.Sprintf("Ombi: %d %s, ", status, err) + } + } + } + } + status, err := app.jf.DeleteUser(userID) + if !(status == 200 || status == 204) || err != nil { + msg := fmt.Sprintf("%d: %s", status, err) + if _, ok := errors[userID]; !ok { + errors[userID] = msg + } else { + errors[userID] += msg + } + } + if req.Notify { + addr, ok := app.storage.emails[userID] + if addr != nil && ok { + go func(userID, reason, address string) { + msg, err := app.email.constructDeleted(reason, app) + if err != nil { + app.err.Printf("%s: Failed to construct account deletion email", userID) + app.debug.Printf("%s: Error: %s", userID, err) + } else if err := app.email.send(address, msg); err != nil { + app.err.Printf("%s: Failed to send to %s", userID, address) + app.debug.Printf("%s: Error: %s", userID, err) + } else { + app.info.Printf("%s: Sent invite email to %s", userID, address) + } + }(userID, req.Reason, addr.(string)) + } + } + } + app.jf.CacheExpiry = time.Now() + if len(errors) == len(req.Users) { + respondBool(500, false, gc) + app.err.Printf("Account deletion failed: %s", errors[req.Users[0]]) + return + } else if len(errors) != 0 { + gc.JSON(500, errors) + return + } + respondBool(200, true, gc) +} + +// @Summary Create a new invite. +// @Produce json +// @Param generateInviteDTO body generateInviteDTO true "New invite request object" +// @Success 200 {object} boolResponse +// @Router /invites [post] +// @Security Bearer +// @tags Invites +func (app *appContext) GenerateInvite(gc *gin.Context) { + var req generateInviteDTO + app.debug.Println("Generating new invite") + app.storage.loadInvites() + gc.BindJSON(&req) + currentTime := time.Now() + validTill := currentTime.AddDate(0, 0, req.Days) + validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes)) + // make sure code doesn't begin with number + inviteCode := shortuuid.New() + _, err := strconv.Atoi(string(inviteCode[0])) + for err == nil { + inviteCode = shortuuid.New() + _, err = strconv.Atoi(string(inviteCode[0])) + } + var invite Invite + invite.Created = currentTime + if req.MultipleUses { + if req.NoLimit { + invite.NoLimit = true + } else { + invite.RemainingUses = req.RemainingUses + } + } else { + invite.RemainingUses = 1 + } + invite.ValidTill = validTill + if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { + app.debug.Printf("%s: Sending invite email", inviteCode) + invite.Email = req.Email + msg, err := app.email.constructInvite(inviteCode, invite, app) + if err != nil { + invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) + app.err.Printf("%s: Failed to construct invite email", inviteCode) + app.debug.Printf("%s: Error: %s", inviteCode, err) + } else if err := app.email.send(req.Email, msg); err != nil { + invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) + app.err.Printf("%s: %s", inviteCode, invite.Email) + app.debug.Printf("%s: Error: %s", inviteCode, err) + } else { + app.info.Printf("%s: Sent invite email to %s", inviteCode, req.Email) + } + } + if req.Profile != "" { + if _, ok := app.storage.profiles[req.Profile]; ok { + invite.Profile = req.Profile + } else { + invite.Profile = "Default" + } + } + app.storage.invites[inviteCode] = invite + app.storage.storeInvites() + respondBool(200, true, gc) +} + +// @Summary Set profile for an invite +// @Produce json +// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /invites/profile [post] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) SetProfile(gc *gin.Context) { + var req inviteProfileDTO + gc.BindJSON(&req) + app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile) + // "" means "Don't apply profile" + if _, ok := app.storage.profiles[req.Profile]; !ok && req.Profile != "" { + app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile) + respond(500, "Profile not found", gc) + return + } + inv := app.storage.invites[req.Invite] + inv.Profile = req.Profile + app.storage.invites[req.Invite] = inv + app.storage.storeInvites() + respondBool(200, true, gc) +} + +// @Summary Get a list of profiles +// @Produce json +// @Success 200 {object} getProfilesDTO +// @Router /profiles [get] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) GetProfiles(gc *gin.Context) { + app.storage.loadProfiles() + app.debug.Println("Profiles requested") + out := getProfilesDTO{ + DefaultProfile: app.storage.defaultProfile, + Profiles: map[string]profileDTO{}, + } + for name, p := range app.storage.profiles { + out.Profiles[name] = profileDTO{ + Admin: p.Admin, + LibraryAccess: p.LibraryAccess, + FromUser: p.FromUser, + } + } + gc.JSON(200, out) +} + +// @Summary Set the default profile to use. +// @Produce json +// @Param profileChangeDTO body profileChangeDTO true "Default profile object" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /profiles/default [post] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) SetDefaultProfile(gc *gin.Context) { + req := profileChangeDTO{} + gc.BindJSON(&req) + app.info.Printf("Setting default profile to \"%s\"", req.Name) + if _, ok := app.storage.profiles[req.Name]; !ok { + app.err.Printf("Profile not found: \"%s\"", req.Name) + respond(500, "Profile not found", gc) + return + } + for name, profile := range app.storage.profiles { + if name == req.Name { + profile.Admin = true + app.storage.profiles[name] = profile + } else { + profile.Admin = false + } + } + app.storage.defaultProfile = req.Name + respondBool(200, true, gc) +} + +// @Summary Create a profile based on a Jellyfin user's settings. +// @Produce json +// @Param newProfileDTO body newProfileDTO true "New profile object" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /profiles [post] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) CreateProfile(gc *gin.Context) { + app.info.Println("Profile creation requested") + var req newProfileDTO + gc.BindJSON(&req) + user, status, err := app.jf.UserByID(req.ID, false) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("Failed to get user from Jellyfin: Code %d", status) + app.debug.Printf("Error: %s", err) + respond(500, "Couldn't get user", gc) + return + } + profile := Profile{ + FromUser: user["Name"].(string), + Policy: user["Policy"].(map[string]interface{}), + } + app.debug.Printf("Creating profile from user \"%s\"", user["Name"].(string)) + if req.Homescreen { + profile.Configuration = user["Configuration"].(map[string]interface{}) + profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("Failed to get DisplayPrefs: Code %d", status) + app.debug.Printf("Error: %s", err) + respond(500, "Couldn't get displayprefs", gc) + return + } + } + app.storage.loadProfiles() + app.storage.profiles[req.Name] = profile + app.storage.storeProfiles() + app.storage.loadProfiles() + respondBool(200, true, gc) +} + +// @Summary Delete an existing profile +// @Produce json +// @Param profileChangeDTO body profileChangeDTO true "Delete profile object" +// @Success 200 {object} boolResponse +// @Router /profiles [delete] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) DeleteProfile(gc *gin.Context) { + req := profileChangeDTO{} + gc.BindJSON(&req) + name := req.Name + if _, ok := app.storage.profiles[name]; ok { + delete(app.storage.profiles, name) + } + app.storage.storeProfiles() + respondBool(200, true, gc) +} + +// @Summary Get invites. +// @Produce json +// @Success 200 {object} getInvitesDTO +// @Router /invites [get] +// @Security Bearer +// @tags Invites +func (app *appContext) GetInvites(gc *gin.Context) { + app.debug.Println("Invites requested") + currentTime := time.Now() + app.storage.loadInvites() + app.checkInvites() + var invites []inviteDTO + for code, inv := range app.storage.invites { + _, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) + invite := inviteDTO{ + Code: code, + Days: days, + Hours: hours, + Minutes: minutes, + Created: app.formatDatetime(inv.Created), + Profile: inv.Profile, + NoLimit: inv.NoLimit, + } + if len(inv.UsedBy) != 0 { + invite.UsedBy = inv.UsedBy + } + invite.RemainingUses = 1 + if inv.RemainingUses != 0 { + invite.RemainingUses = inv.RemainingUses + } + if inv.Email != "" { + invite.Email = inv.Email + } + if len(inv.Notify) != 0 { + var address string + if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { + app.storage.loadEmails() + if addr := app.storage.emails[gc.GetString("jfId")]; addr != nil { + address = addr.(string) + } + } else { + address = app.config.Section("ui").Key("email").String() + } + if _, ok := inv.Notify[address]; ok { + if _, ok = inv.Notify[address]["notify-expiry"]; ok { + invite.NotifyExpiry = inv.Notify[address]["notify-expiry"] + } + if _, ok = inv.Notify[address]["notify-creation"]; ok { + invite.NotifyCreation = inv.Notify[address]["notify-creation"] + } + } + } + invites = append(invites, invite) + } + profiles := make([]string, len(app.storage.profiles)) + if len(app.storage.profiles) != 0 { + profiles[0] = app.storage.defaultProfile + i := 1 + if len(app.storage.profiles) > 1 { + for p := range app.storage.profiles { + if p != app.storage.defaultProfile { + profiles[i] = p + i++ + } + } + } + } + resp := getInvitesDTO{ + Profiles: profiles, + Invites: invites, + } + gc.JSON(200, resp) +} + +// @Summary Set notification preferences for an invite. +// @Produce json +// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects" +// @Success 200 +// @Failure 400 {object} stringResponse +// @Failure 500 {object} stringResponse +// @Router /invites/notify [post] +// @Security Bearer +// @tags Other +func (app *appContext) SetNotify(gc *gin.Context) { + var req map[string]map[string]bool + gc.BindJSON(&req) + changed := false + for code, settings := range req { + app.debug.Printf("%s: Notification settings change requested", code) + app.storage.loadInvites() + app.storage.loadEmails() + invite, ok := app.storage.invites[code] + if !ok { + app.err.Printf("%s Notification setting change failed: Invalid code", code) + respond(400, "Invalid invite code", gc) + return + } + var address string + if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { + var ok bool + address, ok = app.storage.emails[gc.GetString("jfId")].(string) + if !ok { + app.err.Printf("%s: Couldn't find email address. Make sure it's set", code) + app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId")) + respond(500, "Missing user email", gc) + return + } + } else { + address = app.config.Section("ui").Key("email").String() + } + if invite.Notify == nil { + invite.Notify = map[string]map[string]bool{} + } + if _, ok := invite.Notify[address]; !ok { + invite.Notify[address] = map[string]bool{} + } /*else { + if _, ok := invite.Notify[address]["notify-expiry"]; !ok { + */ + if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] { + invite.Notify[address]["notify-expiry"] = settings["notify-expiry"] + app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address) + changed = true + } + if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] { + invite.Notify[address]["notify-creation"] = settings["notify-creation"] + app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address) + changed = true + } + if changed { + app.storage.invites[code] = invite + } + } + if changed { + app.storage.storeInvites() + } +} + +// @Summary Delete an invite. +// @Produce json +// @Param deleteInviteDTO body deleteInviteDTO true "Delete invite object" +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Router /invites [delete] +// @Security Bearer +// @tags Invites +func (app *appContext) DeleteInvite(gc *gin.Context) { + var req deleteInviteDTO + gc.BindJSON(&req) + app.debug.Printf("%s: Deletion requested", req.Code) + var ok bool + _, ok = app.storage.invites[req.Code] + if ok { + delete(app.storage.invites, req.Code) + app.storage.storeInvites() + app.info.Printf("%s: Invite deleted", req.Code) + respondBool(200, true, gc) + return + } + app.err.Printf("%s: Deletion failed: Invalid code", req.Code) + respond(400, "Code doesn't exist", gc) +} + +type dateToParse struct { + Parsed time.Time `json:"parseme"` +} + +func parseDT(date string) time.Time { + // decent method + dt, err := time.Parse("2006-01-02T15:04:05.000000", date) + if err == nil { + return dt + } + // magic method + // some stored dates from jellyfin have no timezone at the end, if not we assume UTC + if date[len(date)-1] != 'Z' { + date += "Z" + } + timeJSON := []byte("{ \"parseme\": \"" + date + "\" }") + var parsed dateToParse + // Magically turn it into a time.Time + json.Unmarshal(timeJSON, &parsed) + return parsed.Parsed +} + +// @Summary Get a list of Jellyfin users. +// @Produce json +// @Success 200 {object} getUsersDTO +// @Failure 500 {object} stringResponse +// @Router /users [get] +// @Security Bearer +// @tags Users +func (app *appContext) GetUsers(gc *gin.Context) { + app.debug.Println("Users requested") + var resp getUsersDTO + resp.UserList = []respUser{} + users, status, err := app.jf.GetUsers(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) + respond(500, "Couldn't get users", gc) + return + } + for _, jfUser := range users { + var user respUser + user.LastActive = "n/a" + if jfUser["LastActivityDate"] != nil { + date := parseDT(jfUser["LastActivityDate"].(string)) + user.LastActive = app.formatDatetime(date) + // fmt.Printf("%s: %s, %s, %+v\n", jfUser["Name"].(string), jfUser["LastActivityDate"].(string), user.LastActive, date) + } + user.ID = jfUser["Id"].(string) + user.Name = jfUser["Name"].(string) + user.Admin = jfUser["Policy"].(map[string]interface{})["IsAdministrator"].(bool) + if email, ok := app.storage.emails[jfUser["Id"].(string)]; ok { + user.Email = email.(string) + } + + resp.UserList = append(resp.UserList, user) + } + gc.JSON(200, resp) +} + +// @Summary Get a list of Ombi users. +// @Produce json +// @Success 200 {object} ombiUsersDTO +// @Failure 500 {object} stringResponse +// @Router /ombi/users [get] +// @Security Bearer +// @tags Ombi +func (app *appContext) OmbiUsers(gc *gin.Context) { + app.debug.Println("Ombi users requested") + users, status, err := app.ombi.GetUsers() + if err != nil || status != 200 { + app.err.Printf("Failed to get users from Ombi: Code %d", status) + app.debug.Printf("Error: %s", err) + respond(500, "Couldn't get users", gc) + return + } + userlist := make([]ombiUser, len(users)) + for i, data := range users { + userlist[i] = ombiUser{ + Name: data["userName"].(string), + ID: data["id"].(string), + } + } + gc.JSON(200, ombiUsersDTO{Users: userlist}) +} + +// @Summary Set new user defaults for Ombi accounts. +// @Produce json +// @Param ombiUser body ombiUser true "User to source settings from" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /ombi/defaults [post] +// @Security Bearer +// @tags Ombi +func (app *appContext) SetOmbiDefaults(gc *gin.Context) { + var req ombiUser + gc.BindJSON(&req) + template, code, err := app.ombi.TemplateByID(req.ID) + if err != nil || code != 200 || len(template) == 0 { + app.err.Printf("Couldn't get user from Ombi: %d %s", code, err) + respond(500, "Couldn't get user", gc) + return + } + app.storage.ombi_template = template + app.storage.storeOmbiTemplate() + respondBool(200, true, gc) +} + +// @Summary Modify user's email addresses. +// @Produce json +// @Param modifyEmailsDTO body modifyEmailsDTO true "Map of userIDs to email addresses" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /users/emails [post] +// @Security Bearer +// @tags Users +func (app *appContext) ModifyEmails(gc *gin.Context) { + var req modifyEmailsDTO + gc.BindJSON(&req) + app.debug.Println("Email modification requested") + users, status, err := app.jf.GetUsers(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) + respond(500, "Couldn't get users", gc) + return + } + ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + for _, jfUser := range users { + id := jfUser["Id"].(string) + if address, ok := req[id]; ok { + app.storage.emails[jfUser["Id"].(string)] = address + if ombiEnabled { + ombiUser, code, err := app.getOmbiUser(id) + if code == 200 && err == nil { + ombiUser["emailAddress"] = address + code, err = app.ombi.ModifyUser(ombiUser) + if code != 200 || err != nil { + app.err.Printf("%s: Failed to change ombi email address: %d %s", ombiUser["userName"].(string), code, err) + } + } + } + } + } + app.storage.storeEmails() + app.info.Println("Email list modified") + respondBool(200, true, gc) +} + +// @Summary Apply settings to a list of users, either from a profile or from another user. +// @Produce json +// @Param userSettingsDTO body userSettingsDTO true "Parameters for applying settings" +// @Success 200 {object} errorListDTO +// @Failure 500 {object} errorListDTO "Lists of errors that occurred while applying settings" +// @Router /users/settings [post] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) ApplySettings(gc *gin.Context) { + app.info.Println("User settings change requested") + var req userSettingsDTO + gc.BindJSON(&req) + applyingFrom := "profile" + var policy, configuration, displayprefs map[string]interface{} + if req.From == "profile" { + app.storage.loadProfiles() + if _, ok := app.storage.profiles[req.Profile]; !ok || len(app.storage.profiles[req.Profile].Policy) == 0 { + app.err.Printf("Couldn't find profile \"%s\" or profile was empty", req.Profile) + respond(500, "Couldn't find profile", gc) + return + } + if req.Homescreen { + if len(app.storage.profiles[req.Profile].Configuration) == 0 || len(app.storage.profiles[req.Profile].Displayprefs) == 0 { + app.err.Printf("No homescreen saved in profile \"%s\"", req.Profile) + respond(500, "No homescreen template available", gc) + return + } + configuration = app.storage.profiles[req.Profile].Configuration + displayprefs = app.storage.profiles[req.Profile].Displayprefs + } + policy = app.storage.profiles[req.Profile].Policy + } else if req.From == "user" { + applyingFrom = "user" + user, status, err := app.jf.UserByID(req.ID, false) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("Failed to get user from Jellyfin: Code %d", status) + app.debug.Printf("Error: %s", err) + respond(500, "Couldn't get user", gc) + return + } + applyingFrom = "\"" + user["Name"].(string) + "\"" + policy = user["Policy"].(map[string]interface{}) + if req.Homescreen { + displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) + if !(status == 200 || status == 204) || err != nil { + app.err.Printf("Failed to get DisplayPrefs: Code %d", status) + app.debug.Printf("Error: %s", err) + respond(500, "Couldn't get displayprefs", gc) + return + } + configuration = user["Configuration"].(map[string]interface{}) + } + } + app.info.Printf("Applying settings to %d user(s) from %s", len(req.ApplyTo), applyingFrom) + errors := errorListDTO{ + "policy": map[string]string{}, + "homescreen": map[string]string{}, + } + for _, id := range req.ApplyTo { + status, err := app.jf.SetPolicy(id, policy) + if !(status == 200 || status == 204) || err != nil { + errors["policy"][id] = fmt.Sprintf("%d: %s", status, err) + } + if req.Homescreen { + status, err = app.jf.SetConfiguration(id, configuration) + errorString := "" + if !(status == 200 || status == 204) || err != nil { + errorString += fmt.Sprintf("Configuration %d: %s ", status, err) + } else { + status, err = app.jf.SetDisplayPreferences(id, displayprefs) + if !(status == 200 || status == 204) || err != nil { + errorString += fmt.Sprintf("Displayprefs %d: %s ", status, err) + } + } + if errorString != "" { + errors["homescreen"][id] = errorString + } + } + } + code := 200 + if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) { + code = 500 + } + gc.JSON(code, errors) +} + +// @Summary Get jfa-go configuration. +// @Produce json +// @Success 200 {object} configDTO "Uses the same format as config-base.json" +// @Router /config [get] +// @Security Bearer +// @tags Configuration +func (app *appContext) GetConfig(gc *gin.Context) { + app.info.Println("Config requested") + resp := map[string]interface{}{} + langPath := filepath.Join(app.localPath, "lang", "form") + app.lang.langFiles, _ = ioutil.ReadDir(langPath) + app.lang.langOptions = make([]string, len(app.lang.langFiles)) + chosenLang := app.config.Section("ui").Key("language").MustString("en-us") + ".json" + for i, f := range app.lang.langFiles { + if f.Name() == chosenLang { + app.lang.chosenIndex = i + } + var langFile map[string]interface{} + file, _ := ioutil.ReadFile(filepath.Join(langPath, f.Name())) + json.Unmarshal(file, &langFile) + + if meta, ok := langFile["meta"]; ok { + app.lang.langOptions[i] = meta.(map[string]interface{})["name"].(string) + } + } + for section, settings := range app.configBase { + if section == "order" { + resp[section] = settings.([]interface{}) + } else { + resp[section] = make(map[string]interface{}) + for key, values := range settings.(map[string]interface{}) { + if key == "order" { + resp[section].(map[string]interface{})[key] = values.([]interface{}) + } else { + resp[section].(map[string]interface{})[key] = values.(map[string]interface{}) + if key != "meta" { + dataType := resp[section].(map[string]interface{})[key].(map[string]interface{})["type"].(string) + configKey := app.config.Section(section).Key(key) + if dataType == "number" { + if val, err := configKey.Int(); err == nil { + resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = val + } + } else if dataType == "bool" { + resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.MustBool(false) + } else if dataType == "select" && key == "language" { + resp[section].(map[string]interface{})[key].(map[string]interface{})["options"] = app.lang.langOptions + resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = app.lang.langOptions[app.lang.chosenIndex] + } else { + resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.String() + } + } + } + } + } + } + // resp["jellyfin"].(map[string]interface{})["language"].(map[string]interface{})["options"].([]string) + gc.JSON(200, resp) +} + +// @Summary Modify app config. +// @Produce json +// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." +// @Success 200 {object} boolResponse +// @Router /config [post] +// @Security Bearer +// @tags Configuration +func (app *appContext) ModifyConfig(gc *gin.Context) { + app.info.Println("Config modification requested") + var req configDTO + gc.BindJSON(&req) + tempConfig, _ := ini.Load(app.configPath) + for section, settings := range req { + if section != "restart-program" { + _, err := tempConfig.GetSection(section) + if err != nil { + tempConfig.NewSection(section) + } + for setting, value := range settings.(map[string]interface{}) { + if section == "ui" && setting == "language" { + for i, lang := range app.lang.langOptions { + if value.(string) == lang { + tempConfig.Section(section).Key(setting).SetValue(strings.Replace(app.lang.langFiles[i].Name(), ".json", "", 1)) + break + } + } + } else { + tempConfig.Section(section).Key(setting).SetValue(value.(string)) + } + } + } + } + tempConfig.SaveTo(app.configPath) + app.debug.Println("Config saved") + gc.JSON(200, map[string]bool{"success": true}) + if req["restart-program"] != nil && req["restart-program"].(bool) { + app.info.Println("Restarting...") + err := app.Restart() + if err != nil { + app.err.Printf("Couldn't restart, try restarting manually. (%s)", err) + } + } + app.loadConfig() + // Reinitialize password validator on config change, as opposed to every applicable request like in python. + if _, ok := req["password_validation"]; ok { + app.debug.Println("Reinitializing validator") + validatorConf := ValidatorConf{ + "characters": app.config.Section("password_validation").Key("min_length").MustInt(0), + "uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0), + "lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0), + "numbers": app.config.Section("password_validation").Key("number").MustInt(0), + "special characters": app.config.Section("password_validation").Key("special").MustInt(0), + } + if !app.config.Section("password_validation").Key("enabled").MustBool(false) { + for key := range validatorConf { + validatorConf[key] = 0 + } + } + app.validator.init(validatorConf) + } +} + +// @Summary Logout by deleting refresh token from cookies. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /logout [post] +// @tags Other +func (app *appContext) Logout(gc *gin.Context) { + cookie, err := gc.Cookie("refresh") + if err != nil { + app.debug.Printf("Couldn't get cookies: %s", err) + respond(500, "Couldn't fetch cookies", gc) + return + } + app.invalidTokens = append(app.invalidTokens, cookie) + gc.SetCookie("refresh", "invalid", -1, "/", gc.Request.URL.Hostname(), true, true) + respondBool(200, true, gc) +} + +// func Restart() error { +// defer func() { +// if r := recover(); r != nil { +// os.Exit(0) +// } +// }() +// cwd, err := os.Getwd() +// if err != nil { +// return err +// } +// args := os.Args +// // for _, key := range args { +// // fmt.Println(key) +// // } +// cmd := exec.Command(args[0], args[1:]...) +// cmd.Stdout = os.Stdout +// cmd.Stderr = os.Stderr +// cmd.Dir = cwd +// err = cmd.Start() +// if err != nil { +// return err +// } +// // cmd.Process.Release() +// panic(fmt.Errorf("restarting")) +// } + +// func (app *appContext) Restart() error { +// defer func() { +// if r := recover(); r != nil { +// signal.Notify(app.quit, os.Interrupt) +// <-app.quit +// } +// }() +// args := os.Args +// // After a single restart, args[0] gets messed up and isnt the real executable. +// // JFA_DEEP tells the new process its a child, and JFA_EXEC is the real executable +// if os.Getenv("JFA_DEEP") == "" { +// os.Setenv("JFA_DEEP", "1") +// os.Setenv("JFA_EXEC", args[0]) +// } +// env := os.Environ() +// err := syscall.Exec(os.Getenv("JFA_EXEC"), []string{""}, env) +// if err != nil { +// return err +// } +// panic(fmt.Errorf("restarting")) +// } + +// no need to syscall.exec anymore! +func (app *appContext) Restart() error { + RESTART <- true + return nil +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..fc927c4 --- /dev/null +++ b/auth.go @@ -0,0 +1,228 @@ +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}) +} diff --git a/common/common.go b/common/common.go new file mode 100644 index 0000000..ed94ad1 --- /dev/null +++ b/common/common.go @@ -0,0 +1,23 @@ +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) + } + } + } +} diff --git a/common/go.mod b/common/go.mod new file mode 100644 index 0000000..ec0c50a --- /dev/null +++ b/common/go.mod @@ -0,0 +1,3 @@ +module github.com/hrfee/jfa-go/common + +go 1.15 diff --git a/config.go b/config.go new file mode 100644 index 0000000..71e1724 --- /dev/null +++ b/config.go @@ -0,0 +1,86 @@ +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 +} diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..db64f2b --- /dev/null +++ b/config/README.md @@ -0,0 +1,11 @@ +### 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. + + diff --git a/config/config-base.json b/config/config-base.json new file mode 100644 index 0000000..21e95a2 --- /dev/null +++ b/config/config-base.json @@ -0,0 +1,668 @@ +{ + "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." + } + } +} diff --git a/config/configStruct.go b/config/configStruct.go new file mode 100644 index 0000000..a58b2d9 --- /dev/null +++ b/config/configStruct.go @@ -0,0 +1,541 @@ +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"` +} diff --git a/config/fixconfig.py b/config/fixconfig.py new file mode 100644 index 0000000..180f2e4 --- /dev/null +++ b/config/fixconfig.py @@ -0,0 +1,27 @@ +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)) + + diff --git a/config/generate_ini.py b/config/generate_ini.py new file mode 100644 index 0000000..efc8c0a --- /dev/null +++ b/config/generate_ini.py @@ -0,0 +1,42 @@ +# 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)) diff --git a/config/jsontostruct.py b/config/jsontostruct.py new file mode 100644 index 0000000..b99be2b --- /dev/null +++ b/config/jsontostruct.py @@ -0,0 +1,57 @@ +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("}") diff --git a/css/base.css b/css/base.css index 227b7e4..447b575 100644 --- a/css/base.css +++ b/css/base.css @@ -1,5 +1,5 @@ -@import "../node_modules/a17t/dist/a17t.css"; -@import "../node_modules/remixicon/fonts/remixicon.css"; +@import "a17t.css"; +@import "remixicon.css"; @import "modal.css"; @import "dark.css"; @import "tooltip.css"; diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..51299dc --- /dev/null +++ b/daemon.go @@ -0,0 +1,50 @@ +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) +} diff --git a/docs/go.mod b/docs/go.mod new file mode 100644 index 0000000..fd8b7c6 --- /dev/null +++ b/docs/go.mod @@ -0,0 +1,8 @@ +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 +) diff --git a/email.go b/email.go new file mode 100644 index 0000000..10bd6c0 --- /dev/null +++ b/email.go @@ -0,0 +1,317 @@ +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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d9eae46 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..421c4c0 --- /dev/null +++ b/go.sum @@ -0,0 +1,431 @@ +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= diff --git a/html/404.html b/html/404.html new file mode 100644 index 0000000..22593ad --- /dev/null +++ b/html/404.html @@ -0,0 +1,21 @@ + + + + 404 - jfa-go + + {{ template "header.html" . }} + + + +
+

Page not found.

+

+ {{ .contactMessage }} +

+
+ + diff --git a/admin.html b/html/admin.html similarity index 98% rename from admin.html rename to html/admin.html index 351e581..be0b4aa 100644 --- a/admin.html +++ b/html/admin.html @@ -2,7 +2,12 @@ - jfa-no + + + {{ template "header.html" . }} + Admin - jfa-go @@ -26,8 +32,8 @@ About × jfa-go banner

jfa-go

-

Version a17t-redesign

-

Commit cb28097

+

Version {{ .version }}

+

Commit {{ .commit }}

Available under the MIT License.

diff --git a/html/form-base.html b/html/form-base.html new file mode 100644 index 0000000..d95a708 --- /dev/null +++ b/html/form-base.html @@ -0,0 +1,10 @@ +{{ define "form-base" }} + + + +{{ end }} diff --git a/html/form-loader.html b/html/form-loader.html new file mode 100644 index 0000000..6585322 --- /dev/null +++ b/html/form-loader.html @@ -0,0 +1 @@ +{{ template "form.html" . }} diff --git a/form.html b/html/form.html similarity index 84% rename from form.html rename to html/form.html index 8d0a0b5..d224b30 100644 --- a/form.html +++ b/html/form.html @@ -2,7 +2,8 @@ - create an account - jfa-no + {{ template "header.html" . }} + {{ .lang.pageTitle }} - + + {{ template "form-base" . }} diff --git a/html/header.html b/html/header.html new file mode 100644 index 0000000..4a83009 --- /dev/null +++ b/html/header.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/html/invalidCode.html b/html/invalidCode.html new file mode 100644 index 0000000..99ed9e0 --- /dev/null +++ b/html/invalidCode.html @@ -0,0 +1,20 @@ + + + + Invalid Code - jfa-go + + {{ template "header.html" . }} + + + +
+

Invalid Code.

+

The above code is either incorrect, or has expired.

+

{{ .contactMessage }}

+
+ + diff --git a/html/setup.html b/html/setup.html new file mode 100644 index 0000000..526d11f --- /dev/null +++ b/html/setup.html @@ -0,0 +1,374 @@ + + + + + + + + + + + Setup - jfa-go + + +
+
+
+
+
+
+
+
Welcome!
+

+ 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. +

+ Get Started +
+ +
+
+
+
Login
+

+ To access the admin page, you'll need to login. Choose how below. +

    +
  • Authorize through Jellyfin: Checks credentials with Jellyfin, allowing you to share login details and grant multiple users access.
  • +
  • Username & Password: Set your own username and password manually.
  • +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + Your email address is only required if you want to recieve activity notifications. +
+
+

+
+ Back + Next +
+
+
+
+
+
Jellyfin
+

+ 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. +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Back + Next +
+
+
+
+
+
+
Email
+

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 Mailgun's API. +

+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587. +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
Notifications
+

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.

+
+ + + +
+
+

+
+ Back + Next +
+
+
+
+
+
Email
+

Just a few more things to get your emails looking great. +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

+
+ Back + Next +
+
+
+
+
+
Password Resets
+

+ 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. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Invite Emails
+

+ 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: +

+

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Password Validation
+

+ Enabling this will display a set of password requirements on the create account page, such as minimum length, uppercase characters, special characters, etc. +

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Help Messages
+

+ Just a few little messages that will display in various places. Leave these alone if you want. +

+
+ + +
+
+ + +
+
+ + +
+
+ Back + Next +
+
+
+
+
+
Finished!
+

+ Press the button below to submit your settings. The program will restart. Once it's done, refresh this page. +

+ +
+
+
+
+
+
+
+ + + diff --git a/jfapi/go.mod b/jfapi/go.mod new file mode 100644 index 0000000..df09909 --- /dev/null +++ b/jfapi/go.mod @@ -0,0 +1,7 @@ +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 diff --git a/jfapi/jfapi.go b/jfapi/jfapi.go new file mode 100644 index 0000000..44f1fa8 --- /dev/null +++ b/jfapi/jfapi.go @@ -0,0 +1,370 @@ +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 + Hyphens 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(userID string) (int, error) { + url := fmt.Sprintf("%s/Users/%s", jf.Server, userID) + req, _ := http.NewRequest("DELETE", url, nil) + for name, value := range jf.header { + req.Header.Add(name, value) + } + resp, err := jf.httpClient.Do(req) + defer jf.timeoutHandler() + return resp.StatusCode, err +} + +// 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)) + if id, ok := result[0]["Id"]; ok { + if id.(string)[8] == '-' { + jf.Hyphens = true + } + } + 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 +} diff --git a/lang/form/en-us.json b/lang/form/en-us.json new file mode 100644 index 0000000..fe94b3f --- /dev/null +++ b/lang/form/en-us.json @@ -0,0 +1,41 @@ +{ + "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" + } + } + } +} diff --git a/lang/form/fr-fr.json b/lang/form/fr-fr.json new file mode 100644 index 0000000..102148e --- /dev/null +++ b/lang/form/fr-fr.json @@ -0,0 +1,42 @@ +{ + "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" + } + } + } +} diff --git a/mail/created.mjml b/mail/created.mjml new file mode 100644 index 0000000..fe72fe6 --- /dev/null +++ b/mail/created.mjml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + jellyfin-accounts + + + + + +

User Created

+

A user was created using code {{ .code }}.

+
+ + + Name + Address + Time + + + {{ .username }} + {{ .address }} + {{ .time }} + +
+
+ + + + Notification emails can be toggled on the admin dashboard. + + + + +
diff --git a/mail/created.txt b/mail/created.txt new file mode 100644 index 0000000..7e3d3df --- /dev/null +++ b/mail/created.txt @@ -0,0 +1,7 @@ +A user was created using code {{ .code }}. + +Name: {{ .username }} +Address: {{ .address }} +Time: {{ .time }} + +Note: Notification emails can be toggled on the admin dashboard. diff --git a/mail/deleted.mjml b/mail/deleted.mjml new file mode 100644 index 0000000..78179e2 --- /dev/null +++ b/mail/deleted.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

Your account was deleted.

+

Reason: {{ .reason }}

+
+
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/deleted.txt b/mail/deleted.txt new file mode 100644 index 0000000..19c07fb --- /dev/null +++ b/mail/deleted.txt @@ -0,0 +1,4 @@ +Your Jellyfin account was deleted. +Reason: {{ .reason }} + +{{ .message }} diff --git a/mail/email.mjml b/mail/email.mjml new file mode 100644 index 0000000..fd1ea1e --- /dev/null +++ b/mail/email.mjml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

Hi {{ .username }},

+

Someone has recently requested a password reset on Jellyfin.

+

If this was you, enter the below pin into the prompt.

+

The 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 }} +
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/email.txt b/mail/email.txt new file mode 100644 index 0000000..6bd59f8 --- /dev/null +++ b/mail/email.txt @@ -0,0 +1,10 @@ +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 }} diff --git a/mail/expired.mjml b/mail/expired.mjml new file mode 100644 index 0000000..9f9c917 --- /dev/null +++ b/mail/expired.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + jellyfin-accounts + + + + + +

Invite Expired.

+

Code {{ .code }} expired at {{ .expiry }}.

+
+
+
+ + + + Notification emails can be toggled on the admin dashboard. + + + + +
diff --git a/mail/expired.txt b/mail/expired.txt new file mode 100644 index 0000000..b834152 --- /dev/null +++ b/mail/expired.txt @@ -0,0 +1,5 @@ +Invite expired. + +Code {{ .code }} expired at {{ .expiry }}. + +Note: Notification emails can be toggled on the admin dashboard. diff --git a/mail/generate.py b/mail/generate.py new file mode 100755 index 0000000..b8921bc --- /dev/null +++ b/mail/generate.py @@ -0,0 +1,38 @@ +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 / "build" / "data" +output.mkdir(parents=True, exist_ok=True) + +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." + ) diff --git a/mail/invite-email.mjml b/mail/invite-email.mjml new file mode 100644 index 0000000..f5d688d --- /dev/null +++ b/mail/invite-email.mjml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

Hi,

+

You've been invited to Jellyfin.

+

To join, click the button below.

+

This invite will expire on {{ .expiry_date }}, at {{ .expiry_time }}, which is in {{ .expires_in }}, so act quick.

+
+ Setup your account +
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/invite-email.txt b/mail/invite-email.txt new file mode 100644 index 0000000..10855f1 --- /dev/null +++ b/mail/invite-email.txt @@ -0,0 +1,8 @@ +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 }} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c450c52 --- /dev/null +++ b/main.go @@ -0,0 +1,745 @@ +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"` +} + +// contains everything the application needs, essentially. Wouldn't do this in the future. +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, "html") + htmlFiles, err := ioutil.ReadDir(templatePath) + if err != nil { + app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath) + 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 .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 may hyphenate user IDs. This checks if the version is equal or higher. + checkVersion := func(version string) int { + numberStrings := strings.Split(version, ".") + n := 0 + for _, s := range numberStrings { + num, err := strconv.Atoi(s) + if err == nil { + n += num + } + } + return n + } + if checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") { + // Get users to check if server uses hyphenated userIDs + app.jf.GetUsers(false) + + noHyphens := true + for id := range app.storage.emails { + if strings.Contains(id, "-") { + noHyphens = false + break + } + } + if noHyphens == app.jf.Hyphens { + var newEmails map[string]interface{} + var status int + var err error + if app.jf.Hyphens { + app.info.Println(aurora.Yellow("Your build of Jellyfin appears to hypenate user IDs. Your emails.json file will be modified to match.")) + time.Sleep(time.Second * time.Duration(3)) + newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails) + } else { + app.info.Println(aurora.Yellow("Your emails.json file uses hyphens, but the Jellyfin server no longer does. It will be modified.")) + time.Sleep(time.Second * time.Duration(3)) + newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails) + } + if status != 200 || err != nil { + app.err.Printf("Failed to get users from Jellyfin: Code %d", status) + app.debug.Printf("Error: %s", err) + app.err.Fatalf("Couldn't upgrade emails.json") + } + bakFile := app.storage.emails_path + ".bak" + err = storeJSON(bakFile, app.storage.emails) + if err != nil { + app.err.Fatalf("couldn't store emails.json backup: %s", err) + } + app.storage.emails = newEmails + err = app.storage.storeEmails() + if err != nil { + app.err.Fatalf("couldn't store emails.json: %s", err) + } + } + } + app.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, "web"), 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, "web"), false))) + router.GET("/invite/:invCode", app.InviteProxy) + if *SWAGGER { + app.info.Print(aurora.Magenta("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) + 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) + } + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..9235317 --- /dev/null +++ b/models.go @@ -0,0 +1,129 @@ +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{} + diff --git a/ombi/go.mod b/ombi/go.mod new file mode 100644 index 0000000..bdc5ffa --- /dev/null +++ b/ombi/go.mod @@ -0,0 +1,5 @@ +module github.com/hrfee/jfa-go/ombi + +replace github.com/hrfee/jfa-go/common => ../common + +go 1.15 diff --git a/ombi/ombi.go b/ombi/ombi.go new file mode 100644 index 0000000..b045d26 --- /dev/null +++ b/ombi/ombi.go @@ -0,0 +1,221 @@ +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 +} diff --git a/package-lock.json b/package-lock.json index 4fb9c28..9bbeb57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,17 @@ { - "name": "jellyfin-accounts", + "name": "jfa-go", "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npm.taobao.org/@babel/runtime/download/@babel/runtime-7.12.5.tgz?cache=0&sync_timestamp=1604441258461&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fruntime%2Fdownload%2F%40babel%2Fruntime-7.12.5.tgz", + "integrity": "sha1-QQ5+SHRB4bNgwpvnFdhw2bmFiC4=", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npm.taobao.org/@sindresorhus/is/download/@sindresorhus/is-0.14.0.tgz", @@ -19,6 +27,11 @@ "defer-to-connect": "^1.0.1" } }, + "@types/node": { + "version": "14.14.16", + "resolved": "https://registry.npm.taobao.org/@types/node/download/@types/node-14.14.16.tgz?cache=0&sync_timestamp=1608756036972&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-14.14.16.tgz", + "integrity": "sha1-PMNR+NSBAd6t/tTJ5PEWBI1De0s=" + }, "a17t": { "version": "0.4.0", "resolved": "https://registry.npm.taobao.org/a17t/download/a17t-0.4.0.tgz", @@ -45,8 +58,7 @@ "abbrev": { "version": "1.1.1", "resolved": "https://registry.npm.taobao.org/abbrev/download/abbrev-1.1.1.tgz", - "integrity": "sha1-+PLIh60Qv2f2NPAFtph/7TF5qsg=", - "dev": true + "integrity": "sha1-+PLIh60Qv2f2NPAFtph/7TF5qsg=" }, "accepts": { "version": "1.3.7", @@ -67,6 +79,11 @@ "string-width": "^3.0.0" } }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npm.taobao.org/ansi-colors/download/ansi-colors-4.1.1.tgz", + "integrity": "sha1-y7muJWv3UK8eqzRPIpqif+lLo0g=" + }, "ansi-regex": { "version": "4.1.0", "resolved": "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-4.1.0.tgz", @@ -163,8 +180,7 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npm.taobao.org/balanced-match/download/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base": { "version": "0.11.2", @@ -258,6 +274,11 @@ "file-uri-to-path": "1.0.0" } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npm.taobao.org/boolbase/download/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npm.taobao.org/boxen/download/boxen-4.2.0.tgz", @@ -367,7 +388,6 @@ "version": "1.1.11", "resolved": "https://registry.npm.taobao.org/brace-expansion/download/brace-expansion-1.1.11.tgz", "integrity": "sha1-PH/L9SnYcibz0vUrlm/1Jx60Qd0=", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -463,6 +483,15 @@ } } }, + "camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npm.taobao.org/camel-case/download/camel-case-3.0.0.tgz?cache=0&sync_timestamp=1606867780255&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcamel-case%2Fdownload%2Fcamel-case-3.0.0.tgz", + "integrity": "sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=", + "requires": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, "camelcase": { "version": "5.3.1", "resolved": "https://registry.npm.taobao.org/camelcase/download/camelcase-5.3.1.tgz?cache=0&sync_timestamp=1603921779318&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcamelcase%2Fdownload%2Fcamelcase-5.3.1.tgz", @@ -496,6 +525,26 @@ } } }, + "cheerio": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npm.taobao.org/cheerio/download/cheerio-1.0.0-rc.3.tgz", + "integrity": "sha1-CUY21CWy6cD065GkbAVjDJoai/Y=", + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.1", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash": "^4.15.0", + "parse5": "^3.0.1" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, "chokidar": { "version": "2.1.8", "resolved": "https://registry.npm.taobao.org/chokidar/download/chokidar-2.1.8.tgz?cache=0&sync_timestamp=1602585326091&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchokidar%2Fdownload%2Fchokidar-2.1.8.tgz", @@ -545,6 +594,21 @@ } } }, + "clean-css": { + "version": "4.2.3", + "resolved": "https://registry.npm.taobao.org/clean-css/download/clean-css-4.2.3.tgz", + "integrity": "sha1-UHtd59l7SO5T2ErbAWD/YhY4D3g=", + "requires": { + "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npm.taobao.org/source-map/download/source-map-0.6.1.tgz", + "integrity": "sha1-dHIq8y6WFOnCh6jQu95IteLxomM=" + } + } + }, "cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npm.taobao.org/cli-boxes/download/cli-boxes-2.2.1.tgz", @@ -607,6 +671,11 @@ "integrity": "sha1-xQSRR51MG9rtLJztMs98fcI2D3g=", "dev": true }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npm.taobao.org/commander/download/commander-2.20.3.tgz?cache=0&sync_timestamp=1607931421020&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.20.3.tgz", + "integrity": "sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npm.taobao.org/component-emitter/download/component-emitter-1.3.0.tgz", @@ -616,8 +685,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concurrently": { "version": "5.3.0", @@ -644,6 +712,15 @@ } } }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npm.taobao.org/config-chain/download/config-chain-1.1.12.tgz", + "integrity": "sha1-D96NCRIA616AjK8l/mGMAvSOTvo=", + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "configstore": { "version": "5.0.1", "resolved": "https://registry.npm.taobao.org/configstore/download/configstore-5.0.1.tgz", @@ -698,6 +775,22 @@ "integrity": "sha1-7yp6lm7BEIM4g2m6oC6+rSKbMNU=", "dev": true }, + "css-select": { + "version": "1.2.0", + "resolved": "https://registry.npm.taobao.org/css-select/download/css-select-1.2.0.tgz?cache=0&sync_timestamp=1608486173035&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-select%2Fdownload%2Fcss-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "requires": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "css-what": { + "version": "2.1.3", + "resolved": "https://registry.npm.taobao.org/css-what/download/css-what-2.1.3.tgz?cache=0&sync_timestamp=1602570920759&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-what%2Fdownload%2Fcss-what-2.1.3.tgz", + "integrity": "sha1-ptdgRXM2X+dGhsPzEcVlE9iChfI=" + }, "date-fns": { "version": "2.16.1", "resolved": "https://registry.npm.taobao.org/date-fns/download/date-fns-2.16.1.tgz?cache=0&sync_timestamp=1598884727856&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdate-fns%2Fdownload%2Fdate-fns-2.16.1.tgz", @@ -799,6 +892,42 @@ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", "dev": true }, + "detect-node": { + "version": "2.0.4", + "resolved": "https://registry.npm.taobao.org/detect-node/download/detect-node-2.0.4.tgz", + "integrity": "sha1-AU7o+PZpxcWAI9pkuBecCDooxGw=" + }, + "dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npm.taobao.org/dom-serializer/download/dom-serializer-0.1.1.tgz?cache=0&sync_timestamp=1607193128529&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdom-serializer%2Fdownload%2Fdom-serializer-0.1.1.tgz", + "integrity": "sha1-HsQFnihLq+027sKUHUqXChic58A=", + "requires": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npm.taobao.org/domelementtype/download/domelementtype-1.3.1.tgz?cache=0&sync_timestamp=1606866110836&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomelementtype%2Fdownload%2Fdomelementtype-1.3.1.tgz", + "integrity": "sha1-0EjESzew0Qp/Kj1f7j9DM9eQSB8=" + }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-2.4.2.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-2.4.2.tgz", + "integrity": "sha1-iAUJfpM9ZehVRvcm1g9euItE+AM=", + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npm.taobao.org/domutils/download/domutils-1.5.1.tgz?cache=0&sync_timestamp=1607393197963&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomutils%2Fdownload%2Fdomutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npm.taobao.org/dot-prop/download/dot-prop-5.3.0.tgz?cache=0&sync_timestamp=1605778162251&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdot-prop%2Fdownload%2Fdot-prop-5.3.0.tgz", @@ -820,6 +949,17 @@ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", "dev": true }, + "editorconfig": { + "version": "0.15.3", + "resolved": "https://registry.npm.taobao.org/editorconfig/download/editorconfig-0.15.3.tgz", + "integrity": "sha1-vvhMTnX7jcsM5c7o79UcFZmb78U=", + "requires": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npm.taobao.org/ee-first/download/ee-first-1.1.1.tgz", @@ -852,6 +992,11 @@ "once": "^1.4.0" } }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npm.taobao.org/entities/download/entities-1.1.2.tgz?cache=0&sync_timestamp=1602897347667&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fentities%2Fdownload%2Fentities-1.1.2.tgz", + "integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY=" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npm.taobao.org/error-ex/download/error-ex-1.3.2.tgz", @@ -1121,6 +1266,11 @@ "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", "dev": true }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npm.taobao.org/fs.realpath/download/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, "fsevents": { "version": "1.2.13", "resolved": "https://registry.npm.taobao.org/fsevents/download/fsevents-1.2.13.tgz?cache=0&sync_timestamp=1608033993741&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffsevents%2Fdownload%2Ffsevents-1.2.13.tgz", @@ -1141,8 +1291,7 @@ "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npm.taobao.org/get-caller-file/download/get-caller-file-2.0.5.tgz", - "integrity": "sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=", - "dev": true + "integrity": "sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=" }, "get-stream": { "version": "4.1.0", @@ -1159,6 +1308,19 @@ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", "dev": true }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npm.taobao.org/glob/download/glob-7.1.6.tgz", + "integrity": "sha1-FB8zuBp8JJLhJVlDB0gMRmeSeKY=", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "glob-parent": { "version": "3.1.0", "resolved": "https://registry.npm.taobao.org/glob-parent/download/glob-parent-3.1.0.tgz?cache=0&sync_timestamp=1584835651798&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fglob-parent%2Fdownload%2Fglob-parent-3.1.0.tgz", @@ -1267,12 +1429,56 @@ "integrity": "sha1-E34RNUp7W/EapctknPDG8/8rLnc=", "dev": true }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npm.taobao.org/he/download/he-1.2.0.tgz", + "integrity": "sha1-hK5l+n6vsWX922FWauFLrwVmTw8=" + }, "hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npm.taobao.org/hosted-git-info/download/hosted-git-info-2.8.8.tgz?cache=0&sync_timestamp=1602801693111&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhosted-git-info%2Fdownload%2Fhosted-git-info-2.8.8.tgz", "integrity": "sha1-dTm9S8Hg4KiVgVouAmJCCxKFhIg=", "dev": true }, + "html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npm.taobao.org/html-minifier/download/html-minifier-4.0.0.tgz", + "integrity": "sha1-zKmq2LzhF14C4XqMM+RtiYiIn1Y=", + "requires": { + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" + } + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npm.taobao.org/htmlparser2/download/htmlparser2-3.10.1.tgz?cache=0&sync_timestamp=1607396725165&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhtmlparser2%2Fdownload%2Fhtmlparser2-3.10.1.tgz", + "integrity": "sha1-vWedw/WYl7ajS7EHSchVu1OpOS8=", + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npm.taobao.org/readable-stream/download/readable-stream-3.6.0.tgz", + "integrity": "sha1-M3u9o63AcGvT4CRCaihtS0sskZg=", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "http-auth": { "version": "3.1.3", "resolved": "https://registry.npm.taobao.org/http-auth/download/http-auth-3.1.3.tgz", @@ -1336,17 +1542,24 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npm.taobao.org/inflight/download/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npm.taobao.org/inherits/download/inherits-2.0.4.tgz", - "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", - "dev": true + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=" }, "ini": { "version": "1.3.7", "resolved": "https://registry.npm.taobao.org/ini/download/ini-1.3.7.tgz?cache=0&sync_timestamp=1607907977465&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fini%2Fdownload%2Fini-1.3.7.tgz", - "integrity": "sha1-oJNj4ZEZcuoW16iFEAXYTPCamoQ=", - "dev": true + "integrity": "sha1-oJNj4ZEZcuoW16iFEAXYTPCamoQ=" }, "is-accessor-descriptor": { "version": "0.1.6", @@ -1455,8 +1668,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npm.taobao.org/is-extglob/download/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-fullwidth-code-point": { "version": "2.0.0", @@ -1468,7 +1680,6 @@ "version": "4.0.1", "resolved": "https://registry.npm.taobao.org/is-glob/download/is-glob-4.0.1.tgz", "integrity": "sha1-dWfb6fL14kZ7x3q4PEopSCQHpdw=", - "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -1566,6 +1777,28 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, + "js-beautify": { + "version": "1.13.0", + "resolved": "https://registry.npm.taobao.org/js-beautify/download/js-beautify-1.13.0.tgz?cache=0&sync_timestamp=1597939451698&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjs-beautify%2Fdownload%2Fjs-beautify-1.13.0.tgz", + "integrity": "sha1-oFbV06z9SRhUmq46sDn588Ue67I=", + "requires": { + "config-chain": "^1.1.12", + "editorconfig": "^0.15.3", + "glob": "^7.1.3", + "mkdirp": "^1.0.4", + "nopt": "^5.0.0" + }, + "dependencies": { + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npm.taobao.org/nopt/download/nopt-5.0.0.tgz?cache=0&sync_timestamp=1597649987736&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnopt%2Fdownload%2Fnopt-5.0.0.tgz", + "integrity": "sha1-UwlCu1ilEvzK/lP+IQ8TolNV3Ig=", + "requires": { + "abbrev": "1" + } + } + } + }, "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npm.taobao.org/json-buffer/download/json-buffer-3.0.0.tgz", @@ -1578,6 +1811,25 @@ "integrity": "sha1-u4Z8+zRQ5pEHwTHRxRS6s9yLyqk=", "dev": true }, + "juice": { + "version": "7.0.0", + "resolved": "https://registry.npm.taobao.org/juice/download/juice-7.0.0.tgz", + "integrity": "sha1-UJvtatu25Luqf7+trE4ug+jIm6M=", + "requires": { + "cheerio": "^1.0.0-rc.3", + "commander": "^5.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^5.0.0" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npm.taobao.org/commander/download/commander-5.1.0.tgz?cache=0&sync_timestamp=1607931421020&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-5.1.0.tgz", + "integrity": "sha1-Rqu9FlL44Fm92u+Zu9yyrZzxea4=" + } + } + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npm.taobao.org/keyv/download/keyv-3.1.0.tgz?cache=0&sync_timestamp=1600337425120&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fkeyv%2Fdownload%2Fkeyv-3.1.0.tgz", @@ -1633,12 +1885,26 @@ "path-exists": "^3.0.0" } }, + "lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npm.taobao.org/lower-case/download/lower-case-1.1.4.tgz?cache=0&sync_timestamp=1606867791834&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flower-case%2Fdownload%2Flower-case-1.1.4.tgz", + "integrity": "sha1-miyr0bno4K6ZOkv31YdcOcQujqw=" + }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npm.taobao.org/lowercase-keys/download/lowercase-keys-1.0.1.tgz", "integrity": "sha1-b54wtHCE2XGnyCD/FabFFnt0wm8=", "dev": true }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npm.taobao.org/lru-cache/download/lru-cache-4.1.5.tgz?cache=0&sync_timestamp=1594427614275&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flru-cache%2Fdownload%2Flru-cache-4.1.5.tgz", + "integrity": "sha1-i75Q6oW+1ZvJ4z3KuCNe6bz0Q80=", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npm.taobao.org/make-dir/download/make-dir-3.1.0.tgz", @@ -1677,6 +1943,11 @@ "object-visit": "^1.0.0" } }, + "mensch": { + "version": "0.3.4", + "resolved": "https://registry.npm.taobao.org/mensch/download/mensch-0.3.4.tgz", + "integrity": "sha1-dw+RtGyxbqWyBO5zV2jD8MSR/s0=" + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npm.taobao.org/micromatch/download/micromatch-3.1.10.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmicromatch%2Fdownload%2Fmicromatch-3.1.10.tgz", @@ -1729,7 +2000,6 @@ "version": "3.0.4", "resolved": "https://registry.npm.taobao.org/minimatch/download/minimatch-3.0.4.tgz", "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1761,6 +2031,922 @@ } } }, + "mjml": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml/download/mjml-4.8.0.tgz", + "integrity": "sha1-JVJsGuVOevJTSAs5KmJCuNfbCkM=", + "requires": { + "@babel/runtime": "^7.8.7", + "mjml-accordion": "4.8.0", + "mjml-body": "4.8.0", + "mjml-button": "4.8.0", + "mjml-carousel": "4.8.0", + "mjml-cli": "4.8.0", + "mjml-column": "4.8.0", + "mjml-core": "4.8.0", + "mjml-divider": "4.8.0", + "mjml-group": "4.8.0", + "mjml-head": "4.8.0", + "mjml-head-attributes": "4.8.0", + "mjml-head-breakpoint": "4.8.0", + "mjml-head-font": "4.8.0", + "mjml-head-html-attributes": "4.8.0", + "mjml-head-preview": "4.8.0", + "mjml-head-style": "4.8.0", + "mjml-head-title": "4.8.0", + "mjml-hero": "4.8.0", + "mjml-image": "4.8.0", + "mjml-migrate": "4.8.0", + "mjml-navbar": "4.8.0", + "mjml-raw": "4.8.0", + "mjml-section": "4.8.0", + "mjml-social": "4.8.0", + "mjml-spacer": "4.8.0", + "mjml-table": "4.8.0", + "mjml-text": "4.8.0", + "mjml-validator": "4.8.0", + "mjml-wrapper": "4.8.0" + } + }, + "mjml-accordion": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-accordion/download/mjml-accordion-4.8.0.tgz", + "integrity": "sha1-btO0jTYQcE5aBSfRD/+Y6UjJjeg=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-body": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-body/download/mjml-body-4.8.0.tgz", + "integrity": "sha1-kMxPjRkzGZ64kb5703bYwwGnZKw=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-button": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-button/download/mjml-button-4.8.0.tgz", + "integrity": "sha1-yh4ncf5Q0FnbC89ezrtHM6yC5LU=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-carousel": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-carousel/download/mjml-carousel-4.8.0.tgz", + "integrity": "sha1-VTgkjBlolICENCwk+ljoaeKQulc=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-cli": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-cli/download/mjml-cli-4.8.0.tgz", + "integrity": "sha1-raSarndmcNIQv6ioYI4o0KsCAos=", + "requires": { + "@babel/runtime": "^7.8.7", + "chokidar": "^3.0.0", + "glob": "^7.1.1", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "lodash": "^4.17.15", + "mjml-core": "4.8.0", + "mjml-migrate": "4.8.0", + "mjml-parser-xml": "4.8.0", + "mjml-validator": "4.8.0", + "yargs": "^16.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-5.0.0.tgz", + "integrity": "sha1-OIU59VF5vzkznIGvMKZU1p+Hy3U=" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-4.3.0.tgz?cache=0&sync_timestamp=1606792436886&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fansi-styles%2Fdownload%2Fansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "requires": { + "color-convert": "^2.0.1" + } + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npm.taobao.org/anymatch/download/anymatch-3.1.1.tgz", + "integrity": "sha1-xV7PAhheJGklk5kxDBc84xIzsUI=", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "binary-extensions": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/binary-extensions/download/binary-extensions-2.1.0.tgz?cache=0&sync_timestamp=1593261253160&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbinary-extensions%2Fdownload%2Fbinary-extensions-2.1.0.tgz", + "integrity": "sha1-MPpAyef+B9vIlWeM0ocCTeokHdk=" + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npm.taobao.org/braces/download/braces-3.0.2.tgz", + "integrity": "sha1-NFThpGLujVmeI23zNs2epPiv4Qc=", + "requires": { + "fill-range": "^7.0.1" + } + }, + "chokidar": { + "version": "3.4.3", + "resolved": "https://registry.npm.taobao.org/chokidar/download/chokidar-3.4.3.tgz?cache=0&sync_timestamp=1602585326091&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchokidar%2Fdownload%2Fchokidar-3.4.3.tgz", + "integrity": "sha1-wd84IxRI5FykrFiObHlXO6alfVs=", + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.5.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npm.taobao.org/cliui/download/cliui-7.0.4.tgz?cache=0&sync_timestamp=1604880226973&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcliui%2Fdownload%2Fcliui-7.0.4.tgz", + "integrity": "sha1-oCZe5lVHb8gHrqnfPfjfd4OAi08=", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npm.taobao.org/color-convert/download/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npm.taobao.org/color-name/download/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npm.taobao.org/emoji-regex/download/emoji-regex-8.0.0.tgz?cache=0&sync_timestamp=1603212200036&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Femoji-regex%2Fdownload%2Femoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npm.taobao.org/fill-range/download/fill-range-7.0.1.tgz", + "integrity": "sha1-GRmmp8df44ssfHflGYU12prN2kA=", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npm.taobao.org/fsevents/download/fsevents-2.1.3.tgz?cache=0&sync_timestamp=1608033993741&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffsevents%2Fdownload%2Ffsevents-2.1.3.tgz", + "integrity": "sha1-+3OHA66NL5/pAMM4Nt3r7ouX8j4=", + "optional": true + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npm.taobao.org/glob-parent/download/glob-parent-5.1.1.tgz?cache=0&sync_timestamp=1584835651798&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fglob-parent%2Fdownload%2Fglob-parent-5.1.1.tgz", + "integrity": "sha1-tsHvQXxOVmPqSY8cRa+saRa7wik=", + "requires": { + "is-glob": "^4.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/is-binary-path/download/is-binary-path-2.1.0.tgz", + "integrity": "sha1-6h9/O4DwZCNug0cPhsCcJU+0Wwk=", + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npm.taobao.org/is-fullwidth-code-point/download/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=" + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npm.taobao.org/is-number/download/is-number-7.0.0.tgz", + "integrity": "sha1-dTU0W4lnNNX4DE0GxQlVUnoU8Ss=" + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npm.taobao.org/readdirp/download/readdirp-3.5.0.tgz?cache=0&sync_timestamp=1602584469356&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Freaddirp%2Fdownload%2Freaddirp-3.5.0.tgz", + "integrity": "sha1-m6dMAZsV02UnjS6Ru4xI17TULJ4=", + "requires": { + "picomatch": "^2.2.1" + } + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npm.taobao.org/string-width/download/string-width-4.2.0.tgz", + "integrity": "sha1-lSGCxGzHssMT0VluYjmSvRY7crU=", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-6.0.0.tgz", + "integrity": "sha1-CxVx3XZpzNTz4G4U7x7tJiJa5TI=", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npm.taobao.org/to-regex-range/download/to-regex-range-5.0.1.tgz", + "integrity": "sha1-FkjESq58jZiKMmAY7XL1tN0DkuQ=", + "requires": { + "is-number": "^7.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npm.taobao.org/wrap-ansi/download/wrap-ansi-7.0.0.tgz?cache=0&sync_timestamp=1589250991473&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwrap-ansi%2Fdownload%2Fwrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npm.taobao.org/y18n/download/y18n-5.0.5.tgz", + "integrity": "sha1-h2nsCNA7HqLfJQCs71YXQ7u5qxg=" + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npm.taobao.org/yargs/download/yargs-16.2.0.tgz?cache=0&sync_timestamp=1607208079987&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs%2Fdownload%2Fyargs-16.2.0.tgz", + "integrity": "sha1-HIK/D2tqZur85+8w43b0mhJHf2Y=", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npm.taobao.org/yargs-parser/download/yargs-parser-20.2.4.tgz", + "integrity": "sha1-tCiQ8UVmeW+Fro46JSkNIF8VSlQ=" + } + } + }, + "mjml-column": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-column/download/mjml-column-4.8.0.tgz", + "integrity": "sha1-icwApZI61wRoUfODhsFuVScJB2w=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-core": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-core/download/mjml-core-4.8.0.tgz", + "integrity": "sha1-sPLZoMhBeBIZHEEbHaoj/dirhMc=", + "requires": { + "@babel/runtime": "^7.8.7", + "cheerio": "1.0.0-rc.3", + "detect-node": "2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^7.0.0", + "lodash": "^4.17.15", + "mjml-migrate": "4.8.0", + "mjml-parser-xml": "4.8.0", + "mjml-validator": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-divider": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-divider/download/mjml-divider-4.8.0.tgz", + "integrity": "sha1-kYXaSDzPAx8kgNXaZee5W2Pcwds=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-group": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-group/download/mjml-group-4.8.0.tgz", + "integrity": "sha1-wPCh5EXIpfFISTefP8CLX5KsshM=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head/download/mjml-head-4.8.0.tgz", + "integrity": "sha1-1o7a5bfm7dxa/IXaHO27mBfzipQ=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-attributes": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-attributes/download/mjml-head-attributes-4.8.0.tgz", + "integrity": "sha1-8AVmW8ybAb2UZK6m4KCXw5i/nT0=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-breakpoint": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-breakpoint/download/mjml-head-breakpoint-4.8.0.tgz", + "integrity": "sha1-MVAnfQXTBIeqkX/iTk89ERYn5ZU=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-font": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-font/download/mjml-head-font-4.8.0.tgz", + "integrity": "sha1-46/bsMdcPPy405Ok9KvQgLXaTZU=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-html-attributes": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-html-attributes/download/mjml-head-html-attributes-4.8.0.tgz", + "integrity": "sha1-q5YdLDYxRod5PSzCRHCtfdFcVjw=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-preview": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-preview/download/mjml-head-preview-4.8.0.tgz", + "integrity": "sha1-ow+RDdJGzm8CdO6oIHFzGDh4wjY=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-style": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-style/download/mjml-head-style-4.8.0.tgz", + "integrity": "sha1-MULyxEmvCbdwIMOGeBrCZOb9tv4=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-head-title": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-head-title/download/mjml-head-title-4.8.0.tgz", + "integrity": "sha1-xUYxY3tPD0xWgnHNI+E33LtiZQc=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-hero": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-hero/download/mjml-hero-4.8.0.tgz", + "integrity": "sha1-k0QBYEt8pk+Q5Ui+1eYXGzzsLnk=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-image": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-image/download/mjml-image-4.8.0.tgz", + "integrity": "sha1-Jo3jucQZxgXbr5ozydipg1+ioqQ=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-migrate": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-migrate/download/mjml-migrate-4.8.0.tgz", + "integrity": "sha1-mR63m1zAUpYNayvLSW42NgicB2E=", + "requires": { + "@babel/runtime": "^7.8.7", + "js-beautify": "^1.6.14", + "lodash": "^4.17.15", + "mjml-core": "4.8.0", + "mjml-parser-xml": "4.8.0", + "yargs": "^16.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-5.0.0.tgz", + "integrity": "sha1-OIU59VF5vzkznIGvMKZU1p+Hy3U=" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-4.3.0.tgz?cache=0&sync_timestamp=1606792436886&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fansi-styles%2Fdownload%2Fansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "requires": { + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npm.taobao.org/cliui/download/cliui-7.0.4.tgz?cache=0&sync_timestamp=1604880226973&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcliui%2Fdownload%2Fcliui-7.0.4.tgz", + "integrity": "sha1-oCZe5lVHb8gHrqnfPfjfd4OAi08=", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npm.taobao.org/color-convert/download/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npm.taobao.org/color-name/download/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npm.taobao.org/emoji-regex/download/emoji-regex-8.0.0.tgz?cache=0&sync_timestamp=1603212200036&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Femoji-regex%2Fdownload%2Femoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npm.taobao.org/is-fullwidth-code-point/download/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=" + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npm.taobao.org/string-width/download/string-width-4.2.0.tgz", + "integrity": "sha1-lSGCxGzHssMT0VluYjmSvRY7crU=", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-6.0.0.tgz", + "integrity": "sha1-CxVx3XZpzNTz4G4U7x7tJiJa5TI=", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npm.taobao.org/wrap-ansi/download/wrap-ansi-7.0.0.tgz?cache=0&sync_timestamp=1589250991473&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwrap-ansi%2Fdownload%2Fwrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npm.taobao.org/y18n/download/y18n-5.0.5.tgz", + "integrity": "sha1-h2nsCNA7HqLfJQCs71YXQ7u5qxg=" + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npm.taobao.org/yargs/download/yargs-16.2.0.tgz?cache=0&sync_timestamp=1607208079987&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs%2Fdownload%2Fyargs-16.2.0.tgz", + "integrity": "sha1-HIK/D2tqZur85+8w43b0mhJHf2Y=", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npm.taobao.org/yargs-parser/download/yargs-parser-20.2.4.tgz", + "integrity": "sha1-tCiQ8UVmeW+Fro46JSkNIF8VSlQ=" + } + } + }, + "mjml-navbar": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-navbar/download/mjml-navbar-4.8.0.tgz", + "integrity": "sha1-aygLhgPICjQ+JkJkFDQpbZISju4=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-parser-xml": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-parser-xml/download/mjml-parser-xml-4.8.0.tgz", + "integrity": "sha1-IfUO4LgD2zWUiGGuthckyN3uS/4=", + "requires": { + "@babel/runtime": "^7.8.7", + "detect-node": "2.0.4", + "htmlparser2": "^4.1.0", + "lodash": "^4.17.15" + }, + "dependencies": { + "dom-serializer": { + "version": "1.2.0", + "resolved": "https://registry.npm.taobao.org/dom-serializer/download/dom-serializer-1.2.0.tgz?cache=0&sync_timestamp=1607193128529&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdom-serializer%2Fdownload%2Fdom-serializer-1.2.0.tgz", + "integrity": "sha1-NDPZE2rrPGJ5gdqjhfx/MtJ8SPE=", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "entities": "^2.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-4.0.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-4.0.0.tgz", + "integrity": "sha1-Aep4Id6ZbYX2kCnoH6hzwhgzCY4=", + "requires": { + "domelementtype": "^2.1.0" + } + } + } + }, + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/domelementtype/download/domelementtype-2.1.0.tgz?cache=0&sync_timestamp=1606866110836&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomelementtype%2Fdownload%2Fdomelementtype-2.1.0.tgz", + "integrity": "sha1-qFHAgKbRw9lDRK7RUdmfZp7fWF4=" + }, + "domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-3.3.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-3.3.0.tgz", + "integrity": "sha1-bbfqRuRhfrFc+HXfaLK4UkzgA3o=", + "requires": { + "domelementtype": "^2.0.1" + } + }, + "domutils": { + "version": "2.4.4", + "resolved": "https://registry.npm.taobao.org/domutils/download/domutils-2.4.4.tgz?cache=0&sync_timestamp=1607393197963&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomutils%2Fdownload%2Fdomutils-2.4.4.tgz", + "integrity": "sha1-KCc5xLFQ0CLTRpl5c2mq2NGbu9M=", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-4.0.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-4.0.0.tgz", + "integrity": "sha1-Aep4Id6ZbYX2kCnoH6hzwhgzCY4=", + "requires": { + "domelementtype": "^2.1.0" + } + } + } + }, + "entities": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/entities/download/entities-2.1.0.tgz?cache=0&sync_timestamp=1602897347667&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fentities%2Fdownload%2Fentities-2.1.0.tgz", + "integrity": "sha1-mS0xKc999ocLlsV4WMJJoSD4uLU=" + }, + "htmlparser2": { + "version": "4.1.0", + "resolved": "https://registry.npm.taobao.org/htmlparser2/download/htmlparser2-4.1.0.tgz?cache=0&sync_timestamp=1607396725165&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhtmlparser2%2Fdownload%2Fhtmlparser2-4.1.0.tgz", + "integrity": "sha1-mk7xYfLkYl6/ffvmwKL1LRilnng=", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^3.0.0", + "domutils": "^2.0.0", + "entities": "^2.0.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-raw": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-raw/download/mjml-raw-4.8.0.tgz", + "integrity": "sha1-7w3inEIZLkraoCP2A5uO/+bmNmY=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-section": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-section/download/mjml-section-4.8.0.tgz", + "integrity": "sha1-Dv81lcp8ZaK83rP3HU7MulnhHSw=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-social": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-social/download/mjml-social-4.8.0.tgz", + "integrity": "sha1-U3TXwSUo+NlvIBZVmca6E/aJ1uQ=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-spacer": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-spacer/download/mjml-spacer-4.8.0.tgz", + "integrity": "sha1-qscNNUNN5fl5voBdJbxEL8Ll3Vg=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-table": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-table/download/mjml-table-4.8.0.tgz", + "integrity": "sha1-M8AGZ78CbN7KDRO4f+drHHzSO/U=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-text": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-text/download/mjml-text-4.8.0.tgz", + "integrity": "sha1-LOvEn/iz+rMcgEd2E8MuwqDPWS8=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mjml-validator": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-validator/download/mjml-validator-4.8.0.tgz", + "integrity": "sha1-eUlj223s1ASLAH43Wd5O49R5SgU=", + "requires": { + "@babel/runtime": "^7.8.7" + } + }, + "mjml-wrapper": { + "version": "4.8.0", + "resolved": "https://registry.npm.taobao.org/mjml-wrapper/download/mjml-wrapper-4.8.0.tgz", + "integrity": "sha1-dYR5lY+47GbIsWKyIpJAeoNsVIo=", + "requires": { + "@babel/runtime": "^7.8.7", + "lodash": "^4.17.15", + "mjml-core": "4.8.0", + "mjml-section": "4.8.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npm.taobao.org/lodash/download/lodash-4.17.20.tgz?cache=0&sync_timestamp=1597335994883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.20.tgz", + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npm.taobao.org/mkdirp/download/mkdirp-1.0.4.tgz", + "integrity": "sha1-PrXtYmInVteaXw4qIh3+utdcL34=" + }, "morgan": { "version": "1.10.0", "resolved": "https://registry.npm.taobao.org/morgan/download/morgan-1.10.0.tgz", @@ -1812,6 +2998,19 @@ "integrity": "sha1-/qz3zPUlp3rpY0Q2pkiD/+yjRvs=", "dev": true }, + "no-case": { + "version": "2.3.2", + "resolved": "https://registry.npm.taobao.org/no-case/download/no-case-2.3.2.tgz?cache=0&sync_timestamp=1606869671099&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fno-case%2Fdownload%2Fno-case-2.3.2.tgz", + "integrity": "sha1-YLgTOWvjmz8SiKTB7V0efSi0ZKw=", + "requires": { + "lower-case": "^1.1.1" + } + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npm.taobao.org/node-fetch/download/node-fetch-2.6.1.tgz?cache=0&sync_timestamp=1599311968037&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnode-fetch%2Fdownload%2Fnode-fetch-2.6.1.tgz", + "integrity": "sha1-BFvTI2Mfdu0uK1VXM5RBa2OaAFI=" + }, "node-releases": { "version": "1.1.67", "resolved": "https://registry.npm.taobao.org/node-releases/download/node-releases-1.1.67.tgz?cache=0&sync_timestamp=1605581679207&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnode-releases%2Fdownload%2Fnode-releases-1.1.67.tgz", @@ -1984,8 +3183,7 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npm.taobao.org/normalize-path/download/normalize-path-3.0.0.tgz", - "integrity": "sha1-Dc1p/yOhybEf0JeDFmRKA4ghamU=", - "dev": true + "integrity": "sha1-Dc1p/yOhybEf0JeDFmRKA4ghamU=" }, "normalize-range": { "version": "0.1.2", @@ -1998,6 +3196,14 @@ "integrity": "sha1-RTNUCH5sqWlXvY9br3U/WYIUISk=", "dev": true }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npm.taobao.org/nth-check/download/nth-check-1.0.2.tgz?cache=0&sync_timestamp=1606861164153&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnth-check%2Fdownload%2Fnth-check-1.0.2.tgz", + "integrity": "sha1-sr0pXDfj3VijvwcAN2Zjuk2c8Fw=", + "requires": { + "boolbase": "~1.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npm.taobao.org/object-assign/download/object-assign-4.1.1.tgz", @@ -2072,7 +3278,6 @@ "version": "1.4.0", "resolved": "https://registry.npm.taobao.org/once/download/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -2136,6 +3341,14 @@ } } }, + "param-case": { + "version": "2.1.1", + "resolved": "https://registry.npm.taobao.org/param-case/download/param-case-2.1.1.tgz?cache=0&sync_timestamp=1606867782594&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparam-case%2Fdownload%2Fparam-case-2.1.1.tgz", + "integrity": "sha1-35T9jPZTHs915r75oIWPvHK+Ikc=", + "requires": { + "no-case": "^2.2.0" + } + }, "parse-json": { "version": "4.0.0", "resolved": "https://registry.npm.taobao.org/parse-json/download/parse-json-4.0.0.tgz?cache=0&sync_timestamp=1598129204872&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse-json%2Fdownload%2Fparse-json-4.0.0.tgz", @@ -2146,6 +3359,14 @@ "json-parse-better-errors": "^1.0.1" } }, + "parse5": { + "version": "3.0.3", + "resolved": "https://registry.npm.taobao.org/parse5/download/parse5-3.0.3.tgz?cache=0&sync_timestamp=1595850937464&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse5%2Fdownload%2Fparse5-3.0.3.tgz", + "integrity": "sha1-BC95L/3TaFFVHPTp4Gazh0q0W1w=", + "requires": { + "@types/node": "*" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npm.taobao.org/parseurl/download/parseurl-1.3.3.tgz", @@ -2173,8 +3394,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npm.taobao.org/path-is-absolute/download/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-parse": { "version": "1.0.6", @@ -2194,8 +3414,7 @@ "picomatch": { "version": "2.2.2", "resolved": "https://registry.npm.taobao.org/picomatch/download/picomatch-2.2.2.tgz", - "integrity": "sha1-IfMz6ba46v8CRo9RRupAbTRfTa0=", - "dev": true + "integrity": "sha1-IfMz6ba46v8CRo9RRupAbTRfTa0=" }, "pify": { "version": "3.0.0", @@ -2226,12 +3445,22 @@ "integrity": "sha1-eCDZsWEgzFXKmud5JoCufbptf+I=", "dev": true }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npm.taobao.org/proto-list/download/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=" + }, "proxy-middleware": { "version": "0.15.0", "resolved": "https://registry.npm.taobao.org/proxy-middleware/download/proxy-middleware-0.15.0.tgz", "integrity": "sha1-o/3xvvtzD5UZZYcqwvYHTGFHelY=", "dev": true }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npm.taobao.org/pseudomap/download/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npm.taobao.org/pstree.remy/download/pstree.remy-1.1.8.tgz", @@ -2312,6 +3541,11 @@ "readable-stream": "^2.0.2" } }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npm.taobao.org/regenerator-runtime/download/regenerator-runtime-0.13.7.tgz?cache=0&sync_timestamp=1595456023687&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fregenerator-runtime%2Fdownload%2Fregenerator-runtime-0.13.7.tgz", + "integrity": "sha1-ysLazIoepnX+qrrriugziYrkb1U=" + }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npm.taobao.org/regex-not/download/regex-not-1.0.2.tgz", @@ -2340,6 +3574,11 @@ "rc": "^1.2.8" } }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npm.taobao.org/relateurl/download/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" + }, "remixicon": { "version": "2.5.0", "resolved": "https://registry.npm.taobao.org/remixicon/download/remixicon-2.5.0.tgz", @@ -2366,8 +3605,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npm.taobao.org/require-directory/download/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "require-main-filename": { "version": "2.0.0", @@ -2418,8 +3656,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npm.taobao.org/safe-buffer/download/safe-buffer-5.1.2.tgz", - "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0=", - "dev": true + "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0=" }, "safe-regex": { "version": "1.1.0", @@ -2433,8 +3670,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npm.taobao.org/semver/download/semver-5.7.1.tgz?cache=0&sync_timestamp=1606851857382&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-5.7.1.tgz", - "integrity": "sha1-qVT5Ma66UI0we78Gnv8MAclhFvc=", - "dev": true + "integrity": "sha1-qVT5Ma66UI0we78Gnv8MAclhFvc=" }, "semver-diff": { "version": "3.1.1", @@ -2570,12 +3806,22 @@ "integrity": "sha1-fpWsskqpL1iF4KvvW6ExMw1K5oM=", "dev": true }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npm.taobao.org/sigmund/download/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=" + }, "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npm.taobao.org/signal-exit/download/signal-exit-3.0.3.tgz", "integrity": "sha1-oUEMLt2PB3sItOJTyOrPyvBXRhw=", "dev": true }, + "slick": { + "version": "1.12.2", + "resolved": "https://registry.npm.taobao.org/slick/download/slick-1.12.2.tgz", + "integrity": "sha1-vQSN23TefRymkV+qSldXCzVQwtc=" + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npm.taobao.org/snapdragon/download/snapdragon-0.8.2.tgz", @@ -2815,7 +4061,6 @@ "version": "1.1.1", "resolved": "https://registry.npm.taobao.org/string_decoder/download/string_decoder-1.1.1.tgz", "integrity": "sha1-nPFhG6YmhdcDCunkujQUnDrwP8g=", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -2946,6 +4191,11 @@ "is-typedarray": "^1.0.0" } }, + "uglify-js": { + "version": "3.12.3", + "resolved": "https://registry.npm.taobao.org/uglify-js/download/uglify-js-3.12.3.tgz?cache=0&sync_timestamp=1609162276146&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fuglify-js%2Fdownload%2Fuglify-js-3.12.3.tgz", + "integrity": "sha1-uybEq+DmjFXpd2vKm+2ZpN9z+s8=" + }, "undefsafe": { "version": "2.0.3", "resolved": "https://registry.npm.taobao.org/undefsafe/download/undefsafe-2.0.3.tgz", @@ -3106,6 +4356,11 @@ } } }, + "upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npm.taobao.org/upper-case/download/upper-case-1.1.3.tgz?cache=0&sync_timestamp=1606859853284&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fupper-case%2Fdownload%2Fupper-case-1.1.3.tgz", + "integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=" + }, "urix": { "version": "0.1.0", "resolved": "https://registry.npm.taobao.org/urix/download/urix-0.1.0.tgz", @@ -3130,8 +4385,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npm.taobao.org/util-deprecate/download/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "utils-merge": { "version": "1.0.1", @@ -3145,6 +4399,11 @@ "integrity": "sha1-sj5DWK+oogL+ehAK8fX4g/AgB+4=", "dev": true }, + "valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npm.taobao.org/valid-data-url/download/valid-data-url-3.0.1.tgz", + "integrity": "sha1-gmwXROcbVjLoR90V29Rbn7OKo08=" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npm.taobao.org/validate-npm-package-license/download/validate-npm-package-license-3.0.4.tgz", @@ -3161,6 +4420,100 @@ "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", "dev": true }, + "web-resource-inliner": { + "version": "5.0.0", + "resolved": "https://registry.npm.taobao.org/web-resource-inliner/download/web-resource-inliner-5.0.0.tgz", + "integrity": "sha1-rDDbgJaTHyCnwbOt5U/0ROLiD3s=", + "requires": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^4.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "1.2.0", + "resolved": "https://registry.npm.taobao.org/dom-serializer/download/dom-serializer-1.2.0.tgz?cache=0&sync_timestamp=1607193128529&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdom-serializer%2Fdownload%2Fdom-serializer-1.2.0.tgz", + "integrity": "sha1-NDPZE2rrPGJ5gdqjhfx/MtJ8SPE=", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "entities": "^2.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-4.0.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-4.0.0.tgz", + "integrity": "sha1-Aep4Id6ZbYX2kCnoH6hzwhgzCY4=", + "requires": { + "domelementtype": "^2.1.0" + } + } + } + }, + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/domelementtype/download/domelementtype-2.1.0.tgz?cache=0&sync_timestamp=1606866110836&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomelementtype%2Fdownload%2Fdomelementtype-2.1.0.tgz", + "integrity": "sha1-qFHAgKbRw9lDRK7RUdmfZp7fWF4=" + }, + "domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-3.3.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-3.3.0.tgz", + "integrity": "sha1-bbfqRuRhfrFc+HXfaLK4UkzgA3o=", + "requires": { + "domelementtype": "^2.0.1" + } + }, + "domutils": { + "version": "2.4.4", + "resolved": "https://registry.npm.taobao.org/domutils/download/domutils-2.4.4.tgz?cache=0&sync_timestamp=1607393197963&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomutils%2Fdownload%2Fdomutils-2.4.4.tgz", + "integrity": "sha1-KCc5xLFQ0CLTRpl5c2mq2NGbu9M=", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npm.taobao.org/domhandler/download/domhandler-4.0.0.tgz?cache=0&sync_timestamp=1606872524192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomhandler%2Fdownload%2Fdomhandler-4.0.0.tgz", + "integrity": "sha1-Aep4Id6ZbYX2kCnoH6hzwhgzCY4=", + "requires": { + "domelementtype": "^2.1.0" + } + } + } + }, + "entities": { + "version": "2.1.0", + "resolved": "https://registry.npm.taobao.org/entities/download/entities-2.1.0.tgz?cache=0&sync_timestamp=1602897347667&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fentities%2Fdownload%2Fentities-2.1.0.tgz", + "integrity": "sha1-mS0xKc999ocLlsV4WMJJoSD4uLU=" + }, + "escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npm.taobao.org/escape-goat/download/escape-goat-3.0.0.tgz", + "integrity": "sha1-6LX7ZYVT/oo8SVnDFsbruMhCsZw=" + }, + "htmlparser2": { + "version": "4.1.0", + "resolved": "https://registry.npm.taobao.org/htmlparser2/download/htmlparser2-4.1.0.tgz?cache=0&sync_timestamp=1607396725165&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhtmlparser2%2Fdownload%2Fhtmlparser2-4.1.0.tgz", + "integrity": "sha1-mk7xYfLkYl6/ffvmwKL1LRilnng=", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^3.0.0", + "domutils": "^2.0.0", + "entities": "^2.0.0" + } + }, + "mime": { + "version": "2.4.7", + "resolved": "https://registry.npm.taobao.org/mime/download/mime-2.4.7.tgz?cache=0&sync_timestamp=1608084213475&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime%2Fdownload%2Fmime-2.4.7.tgz", + "integrity": "sha1-lirtm+DtGckf19wuzl1/TompDXQ=" + } + } + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npm.taobao.org/websocket-driver/download/websocket-driver-0.7.4.tgz", @@ -3247,8 +4600,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npm.taobao.org/wrappy/download/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "3.0.3", @@ -3274,6 +4626,11 @@ "integrity": "sha1-jbK4PDHF11CZu4kLI/MJSJHiR9Q=", "dev": true }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npm.taobao.org/yallist/download/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, "yargs": { "version": "13.3.2", "resolved": "https://registry.npm.taobao.org/yargs/download/yargs-13.3.2.tgz?cache=0&sync_timestamp=1607208079987&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs%2Fdownload%2Fyargs-13.3.2.tgz", diff --git a/package.json b/package.json index 22613e8..e547a4f 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,7 @@ "description": "This is only used for grabbing scss build dependencies, and isn't a real package.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "ts": "nodemon -e ts --watch ts --exec 'sh -c \"esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./js/\"'", - "serve": "npx live-server --ignore=\"ts/**,node_modules/**\"", - "serve-ts": "concurrently --kill-others \"npm run serve\" \"npm run ts\"" + "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", @@ -20,22 +17,13 @@ }, "homepage": "https://github.com/hrfee/jfa-go#readme", "dependencies": { - "@types/jquery": "^3.5.3", "a17t": "^0.4.0", - "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", + "mjml": "^4.8.0", "remixicon": "^2.5.0", "typescript": "^4.0.3" }, "devDependencies": { - "concurrently": "^5.3.0", - "live-server": "^1.2.1", - "nodemon": "^2.0.6" } } diff --git a/pwreset.go b/pwreset.go new file mode 100644 index 0000000..7e91a47 --- /dev/null +++ b/pwreset.go @@ -0,0 +1,105 @@ +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) + } + } +} diff --git a/pwval.go b/pwval.go new file mode 100644 index 0000000..96dcbc3 --- /dev/null +++ b/pwval.go @@ -0,0 +1,70 @@ +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 +} diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..b6f4502 --- /dev/null +++ b/setup.go @@ -0,0 +1,26 @@ +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}) +} diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..43660ed8c50042f7a3db05c281893a5e90c32e25 GIT binary patch literal 6043 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 MnyzYvidESE067adssI20 literal 0 HcmV?d00001 diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..0eed6a0056b615dc363c7df5a012241911c66257 GIT binary patch literal 40781 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 |qY;UNm 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 literal 0 HcmV?d00001 diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6f88cbfb7a68f25748eeb5755a6d64b1e18f6c69 GIT binary patch literal 3585 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 literal 0 HcmV?d00001 diff --git a/static/banner.svg b/static/banner.svg new file mode 100644 index 0000000..627a830 --- /dev/null +++ b/static/banner.svg @@ -0,0 +1,283 @@ + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/browserconfig.xml b/static/browserconfig.xml new file mode 100644 index 0000000..5cd27e3 --- /dev/null +++ b/static/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #603cba + + + diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1563306b1baf6d4397900e205863a089bb3de4de GIT binary patch literal 1034 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 \ No newline at end of file diff --git a/static/site.webmanifest b/static/site.webmanifest new file mode 100644 index 0000000..42eeed3 --- /dev/null +++ b/static/site.webmanifest @@ -0,0 +1,19 @@ +{ + "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" +} diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..689d6a1 --- /dev/null +++ b/storage.go @@ -0,0 +1,236 @@ +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 +} + +// One build of JF 10.7.0 hyphenated user IDs while another one later didn't. These functions will hyphenate/de-hyphenate email storage. + +func hyphenate(userID string) string { + if userID[8] == '-' { + return userID + } + return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:] +} + +func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { + jfUsers, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil { + return nil, status, err + } + newEmails := map[string]interface{}{} + for _, user := range jfUsers { + unHyphenated := user["Id"].(string) + hyphenated := hyphenate(unHyphenated) + email, ok := old[hyphenated] + if ok { + newEmails[unHyphenated] = email + } + } + return newEmails, status, err +} + +func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { + jfUsers, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil { + return nil, status, err + } + newEmails := map[string]interface{}{} + for _, user := range jfUsers { + unstripped := user["Id"].(string) + stripped := strings.ReplaceAll(unstripped, "-", "") + email, ok := old[stripped] + if ok { + newEmails[unstripped] = email + } + } + return newEmails, status, err +} diff --git a/ts/admin.ts b/ts/admin.ts index 57bd20f..be0e53c 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -1,5 +1,6 @@ import { toggleTheme, loadTheme } from "./modules/theme.js"; import { Modal } from "./modules/modal.js"; +import { Tabs } from "./modules/tabs.js"; loadTheme(); (document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme; @@ -78,10 +79,46 @@ invEmailEnabled.onchange = () => { checkEmailEnabled(invEmailEnabled, 2); }; checkInfUses(invInfUses, 0); checkEmailEnabled(invEmailEnabled, 0); + + const loadAccounts = function () { const rows: HTMLTableRowElement[] = Array.from(document.getElementById("accounts-list").children); + const selectAll = document.getElementById("accounts-select-all") as HTMLInputElement; + selectAll.onchange = () => { + for (let i = 0; i < rows.length; i++) { + (rows[i].querySelector("input[type=checkbox]") as HTMLInputElement).checked = selectAll.checked; + } + } + + const checkAllChecks = (state: boolean): boolean => { + let s = true; + for (let i = 0; i < rows.length; i++) { + const selectCheck = rows[i].querySelector("input[type=checkbox]") as HTMLInputElement; + if (selectCheck.checked != state) { + s = false; + break; + } + } + return s + } + for (let i = 0; i < rows.length; i++) { const row = rows[i]; + const selectCheck = row.querySelector("input[type=checkbox]") as HTMLInputElement; + selectCheck.addEventListener("change", () => { + if (checkAllChecks(false)) { + selectAll.indeterminate = false; + selectAll.checked = false; + } else if (checkAllChecks(true)) { + selectAll.checked = true; + selectAll.indeterminate = false; + } else { + selectAll.indeterminate = true; + selectAll.checked = false; + } + + + }); const editButton = row.querySelector(".icon") as HTMLElement; const emailInput = row.querySelector(".input") as HTMLInputElement; const outerClickListener = (event: Event) => { @@ -105,8 +142,6 @@ const loadAccounts = function () { } }; -loadAccounts(); - const modifySettingsSource = function () { const profile = document.getElementById('radio-use-profile') as HTMLInputElement; const user = document.getElementById('radio-use-user') as HTMLInputElement; @@ -138,48 +173,118 @@ const checkDeleteUserNotify = function () { (document.getElementById('delete-user-notify') as HTMLInputElement).onchange = checkDeleteUserNotify; checkDeleteUserNotify(); -const tabs = ["invitesTab", "accountsTab", "settingsTab"] -for (let tab of tabs) { - (document.getElementById(`${tab}-button`) as HTMLSpanElement).onclick = function () { - for (let t of tabs) { - const tabEl = document.getElementById(t) as HTMLDivElement; - const tabButtonEl = document.getElementById(`${t}-button`) as HTMLSpanElement; - if (t == tab) { - tabButtonEl.classList.add("active", "~urge"); - tabEl.classList.remove("unfocused"); - } else { - tabButtonEl.classList.remove("active"); - tabButtonEl.classList.remove("~urge"); - tabEl.classList.add("unfocused"); - } - } - } + +// load tabs +window.tabs = new Tabs(); +for (let tabID of ["invitesTab", "accountsTab", "settingsTab"]) { + window.tabs.addTab(tabID); +} +window.tabs.addTab("accountsTab", loadAccounts); + +// load modals +(() => { + window.modals = {} as Modals; + + window.modals.login = new Modal(document.getElementById('modal-login'), true); + document.getElementById('modalButton').onclick = window.modals.login.toggle; + + window.modals.addUser = new Modal(document.getElementById('modal-add-user')); + (document.getElementById('accounts-add-user') as HTMLSpanElement).onclick = window.modals.addUser.toggle; + document.getElementById('form-add-user').addEventListener('submit', window.modals.addUser.close); + + window.modals.about = new Modal(document.getElementById('modal-about')); + (document.getElementById('setting-about') as HTMLSpanElement).onclick = window.modals.about.toggle; + + window.modals.modifyUser = new Modal(document.getElementById('modal-modify-user')); + document.getElementById('form-modify-user').addEventListener('submit', window.modals.modifyUser.close); + (document.getElementById('accounts-modify-user') as HTMLSpanElement).onclick = window.modals.modifyUser.toggle; + + window.modals.deleteUser = new Modal(document.getElementById('modal-delete-user')); + document.getElementById('form-delete-user').addEventListener('submit', window.modals.deleteUser.close); + (document.getElementById('accounts-delete-user') as HTMLSpanElement).onclick = window.modals.deleteUser.toggle; + + window.modals.settingsRestart = new Modal(document.getElementById('modal-restart')); + + window.modals.settingsRefresh = new Modal(document.getElementById('modal-refresh')); + + window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults')); + document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close); +})(); + + +function errorMessage(aside: HTMLElement, content: string, timeout: number = 4) { + aside.textContent = content; + aside.classList.remove("unfocused"); + setTimeout(() => { aside.classList.add("unfocused"); }, timeout*1000); } -window.modals = {} as Modals; +window.token = ""; -window.modals.login = new Modal(document.getElementById('modal-login'), true); -document.getElementById('form-login').addEventListener('submit', window.modals.login.close); -document.getElementById('modalButton').onclick = window.modals.login.toggle; +function login(username: string, password: string) { + 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 = "Connection error."; + if (this.response) { + errorMsg = this.response["error"]; + } + if (!errorMsg) { + errorMsg = "Unknown error"; + } + if (!refresh) { + errorMessage(window.modals.login.modal.querySelector("aside"), errorMsg, 5); + + } else { + window.modals.login.show(); + } + } else { + const data = this.response; + window.token = data["token"]; + window.modals.login.close(); + /*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(); +} -window.modals.addUser = new Modal(document.getElementById('modal-add-user')); -(document.getElementById('accounts-add-user') as HTMLSpanElement).onclick = window.modals.addUser.toggle; -document.getElementById('form-add-user').addEventListener('submit', window.modals.addUser.close); +document.getElementById('form-login').addEventListener('submit', (event: Event) => { + event.preventDefault(); + const username = (document.getElementById("login-user") as HTMLInputElement).value; + const password = (document.getElementById("login-password") as HTMLInputElement).value; + if (!username || !password) { + errorMessage(window.modals.login.modal.querySelector("aside"), "The username and/or password were left blank." , 4); + return; + } + login(username, password, true); +}); -window.modals.about = new Modal(document.getElementById('modal-about')); -(document.getElementById('setting-about') as HTMLSpanElement).onclick = window.modals.about.toggle; - -window.modals.modifyUser = new Modal(document.getElementById('modal-modify-user')); -document.getElementById('form-modify-user').addEventListener('submit', window.modals.modifyUser.close); -(document.getElementById('accounts-modify-user') as HTMLSpanElement).onclick = window.modals.modifyUser.toggle; - -window.modals.deleteUser = new Modal(document.getElementById('modal-delete-user')); -document.getElementById('form-delete-user').addEventListener('submit', window.modals.deleteUser.close); -(document.getElementById('accounts-delete-user') as HTMLSpanElement).onclick = window.modals.deleteUser.toggle; - -window.modals.settingsRestart = new Modal(document.getElementById('modal-restart')); - -window.modals.settingsRefresh = new Modal(document.getElementById('modal-refresh')); - -window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults')); -document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close); +login("", ""); diff --git a/ts/modules/tabs.ts b/ts/modules/tabs.ts new file mode 100644 index 0000000..c2f60db --- /dev/null +++ b/ts/modules/tabs.ts @@ -0,0 +1,34 @@ +export class Tabs implements Tabs { + tabs: Array; + + constructor() { + this.tabs = []; + } + + addTab = (tabID: string, preFunc = () => void {}, postFunc = () => void {}) => { + let tab = {} as Tab; + tab.tabID = tabID; + tab.tabEl = document.getElementById(tabID) as HTMLDivElement; + tab.buttonEl = document.getElementById(tabID + "-button") as HTMLSpanElement; + tab.buttonEl.onclick = () => { this.switch(tabID); }; + tab.preFunc = preFunc; + tab.postFunc = postFunc; + this.tabs.push(tab); + } + + switch = (tabID: string) => { + for (let t of this.tabs) { + if (t.tabID == tabID) { + t.buttonEl.classList.add("active", "~urge"); + t.preFunc(); + t.tabEl.classList.remove("unfocused"); + } else { + t.buttonEl.classList.remove("active"); + t.buttonEl.classList.remove("~urge"); + t.tabEl.classList.add("unfocused"); + } + t.postFunc(); + } + } +} + diff --git a/ts/modules/theme.js b/ts/modules/theme.ts similarity index 100% rename from ts/modules/theme.js rename to ts/modules/theme.ts diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 826d398..cf0ea07 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -21,8 +21,24 @@ declare interface Window { buttonWidth: number; transitionEvent: string; animationEvent: string; + tabs: Tabs } +declare interface Tabs { + tabs: Array; + addTab: (tabID: string, preFunc?: () => void, postFunc?: () => void) => void; + switch: (tabID: string) => void; +} + +declare interface Tab { + tabID: string; + tabEl: HTMLDivElement; + buttonEl: HTMLSpanElement; + preFunc?: () => void; + postFunc?: () => void; +} + + declare interface Modals { about: Modal; login: Modal; diff --git a/version.py b/version.py new file mode 100644 index 0000000..387e23e --- /dev/null +++ b/version.py @@ -0,0 +1,27 @@ +import subprocess +import sys +import os +try: + version = sys.argv[1].replace('v', '') +except IndexError: + version = "git" + +if version == "auto": + try: + version = subprocess.check_output("git describe --exact-match HEAD".split()).decode("utf-8").rstrip().replace('v', '') + except subprocess.CalledProcessError as e: + if e.returncode == 128: + version = "git" + +commit = subprocess.check_output("git rev-parse --short HEAD".split()).decode("utf-8").rstrip() + +file = f'package main; const VERSION = "{version}"; const COMMIT = "{commit}";' + +try: + writeto = sys.argv[2] +except IndexError: + writeto = "version.go" + +with open(writeto, 'w') as f: + f.write(file) + diff --git a/views.go b/views.go new file mode 100644 index 0000000..40e70b1 --- /dev/null +++ b/views.go @@ -0,0 +1,68 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func gcHTML(gc *gin.Context, code int, file string, templ gin.H) { + gc.Header("Cache-Control", "no-cache") + gc.HTML(code, file, templ) +} + +func (app *appContext) AdminPage(gc *gin.Context) { + emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool() + notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool() + ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + gcHTML(gc, http.StatusOK, "admin.html", gin.H{ + "urlBase": app.URLBase, + "contactMessage": "", + "email_enabled": emailEnabled, + "notifications": notificationsEnabled, + "version": VERSION, + "commit": COMMIT, + "ombiEnabled": ombiEnabled, + "username": !app.config.Section("email").Key("no_username").MustBool(false), + }) +} + +func (app *appContext) InviteProxy(gc *gin.Context) { + code := gc.Param("invCode") + /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */ + // if app.checkInvite(code, false, "") { + if _, ok := app.storage.invites[code]; ok { + email := app.storage.invites[code].Email + if strings.Contains(email, "Failed") { + email = "" + } + gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{ + "urlBase": app.URLBase, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + "helpMessage": app.config.Section("ui").Key("help_message").String(), + "successMessage": app.config.Section("ui").Key("success_message").String(), + "jfLink": app.config.Section("jellyfin").Key("public_server").String(), + "validate": app.config.Section("password_validation").Key("enabled").MustBool(false), + "requirements": app.validator.getCriteria(), + "email": email, + "bs5": app.config.Section("ui").Key("bs5").MustBool(false), + "username": !app.config.Section("email").Key("no_username").MustBool(false), + "lang": app.storage.lang.Form["strings"], + }) + } else { + gcHTML(gc, 404, "invalidCode.html", gin.H{ + "bs5": app.config.Section("ui").Key("bs5").MustBool(false), + "cssFile": app.cssFile, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + } +} + +func (app *appContext) NoRouteHandler(gc *gin.Context) { + gcHTML(gc, 404, "404.html", gin.H{ + "bs5": app.config.Section("ui").Key("bs5").MustBool(false), + "cssFile": app.cssFile, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) +}