Compare commits

..

52 Commits

Author SHA1 Message Date
Harvey Tindall
6e3d5dac19 use newJellyfin instead of constructor method 2020-08-30 20:44:10 +01:00
Harvey Tindall
072776c15f add public_server input to setup 2020-08-30 18:21:53 +01:00
Harvey Tindall
1c980cf7cd Use bs5-jf on setup, fix bugs
No longer quits if the program times out connecting to the given
jellyfin host.
2020-08-30 18:09:06 +01:00
Harvey Tindall
c6f845296a fix alignment on setup page, change invite generator column widths 2020-08-30 17:40:18 +01:00
Harvey Tindall
a5a721b07c Fix broken theme button after changing bootstrap version
Also fix the theme cookie if it's for the wrong version.
2020-08-27 21:10:56 +01:00
Harvey Tindall
086fd0ef2f fix display of blank emails and naming of fields
the input areas in the user email menu were incorrectly identified by
the email address, which caused duplicate ids on blank fields, and
probably stopped any changes from being applied.
2020-08-27 20:41:38 +01:00
Harvey Tindall
d12335bb4a cleaned up auth 2020-08-23 14:59:07 +01:00
Harvey Tindall
0e39b2b699 remove debug fmt.Printlns 2020-08-20 20:35:50 +01:00
Harvey Tindall
ee3b421566 Fixed flaw with jellyfin_login; store refresh token in cookies
with jellyfin_login enabled, the username and password vals in the User
struct would be "". If you disabled 'required' on the login form, blank
username and password would allow you in.
2020-08-20 20:20:31 +01:00
Harvey Tindall
d144077e62 Add refresh tokens for persistent login, logout button
the main JWT is stored temporarily, whereas the refresh token is stored
as a cookie and can only be used to obtain a new main token. Logout
button adds token to blocklist internally and deletes JWT and refresh
token from browser storage.
2020-08-19 22:30:54 +01:00
Harvey Tindall
29a79a1ce1 Moved PKGBUILD to AUR, mention in readme 2020-08-19 16:43:37 +01:00
Harvey Tindall
681d2ce38d link to usr/bin, add license 2020-08-19 16:11:06 +01:00
Harvey Tindall
cee5489da6 typo 2020-08-19 15:53:06 +01:00
Harvey Tindall
b38af84b35 bump PKGBUILD to 0.1.4 2020-08-19 15:40:32 +01:00
Harvey Tindall
8fc9ed1c3c Improve mobile experience
the generate invite box now wraps into 1 column automatically.
the invite code is ellipsized on small displays.
the dropdown button has a large tappable area, and is aligned correctly.
2020-08-19 15:34:16 +01:00
Harvey Tindall
6781316474 provide error message on login and display it nicely
server now provides a reason for login fail to the web ui, and displays
it inside the login button, which looks a lot nicer than the previously
used error box.
2020-08-19 14:50:16 +01:00
Harvey Tindall
daf190f68b Avoid panic on invalid password with jellyfin_login
jfId was assigned too early, before checking errors.
Also, handle 400 as well as 401 from jellyfin as an invalid password.
2020-08-19 14:36:15 +01:00
Harvey Tindall
56478e96c9 create new css link to smoothly transition between themes
Previously, directly editing the <link> tag with the new file would
cause the page to have no stylesheet for a moment while the new file is
downloaded. A new element is now appended below the original instead,
which smoothens out the transition.
2020-08-19 14:31:41 +01:00
Harvey Tindall
ec7609ed8c Add debug flag; warning label for debug mode 2020-08-19 14:09:48 +01:00
Harvey Tindall
6366239ec4 mention placing inside /opt 2020-08-19 12:31:16 +01:00
Harvey Tindall
e893c9a234 Mention PKGBUILD 2020-08-19 12:28:39 +01:00
Harvey Tindall
7879fd2581 Working PKGBUILD
Its a little rough but seems to work.
2020-08-19 12:25:43 +01:00
Harvey Tindall
c778837593 Added install step to makefile; start PKGBUILD
Also added MIT License
2020-08-19 12:10:34 +01:00
Harvey Tindall
af47cd9f0b Improve README 2020-08-18 15:47:03 +01:00
Harvey Tindall
151062fbc1 set gin mode before router initialization
this was previously set after the router was created, causing a debug
message every start.
2020-08-17 12:33:26 +01:00
Harvey Tindall
abc51f2443 Shrunk dockerfile
Dockerfile now has separate build stage, and uses debian. Image now
sits at ~300MB.
2020-08-17 11:32:34 +01:00
Harvey Tindall
8c4bd4541c Added non interactive makefile option; fixed flags again
fixed another problem with the -data option, and added 'make headless'
for use in the dockerfile
2020-08-16 20:11:16 +01:00
Harvey Tindall
8750efe101 added user caching with 30m timeout 2020-08-16 16:08:37 +01:00
Harvey Tindall
252e13757b Disable i386 builds
disabling this because building for darwin/386 is no longer available
and goreleaser hasn't picked up on this yet, i believe.
2020-08-16 14:55:39 +01:00
Harvey Tindall
02183c7fcc added -y option to prebuild scripts using node_bin
similar to apt, -y assumes yes to all questions, specifically if
node_bin is correct here. This is necessary for goreleaser, as it is not
interactive.
2020-08-16 14:39:47 +01:00
Harvey Tindall
dd0eabf157 Upgrade packages 2020-08-16 14:33:10 +01:00
Harvey Tindall
6436dba48f fixed custom config and data paths
any specified custom data path was only being used for the config file.
All combinations of options should work together now.
2020-08-16 14:26:07 +01:00
Harvey Tindall
bd8af153a9 disable generate button if duration is zero 2020-08-16 14:05:16 +01:00
Harvey Tindall
fd766e7b1a use app identifier instead of ctx
changing this because ctx is commonly used with the context package.
2020-08-16 13:36:54 +01:00
Harvey Tindall
fffb3471d6 Merge branch 'main' of github.com:hrfee/jfa-go into main 2020-08-15 22:10:28 +01:00
Harvey Tindall
19bd31d968 attempt at using a config struct instead of the ini library
Added script to convert config-base.json into a go struct, so that
access to config values and metadata could be unified and simpler. It
probably won't see any actual use though as mapping the ini into it is
painful.
2020-08-15 22:07:48 +01:00
Harvey Tindall
39bf3ad7f1 Safe shutdown 2020-08-05 16:58:24 +01:00
Harvey Tindall
ea5c2b3886 Delete nohup.out 2020-08-04 23:50:33 +01:00
Harvey Tindall
8a8fe65192 Add windows build support from jf-accounts 2020-08-04 18:24:11 +01:00
Harvey Tindall
5329f02768 add docker to readme 2020-08-03 20:10:45 +01:00
Harvey Tindall
7b23545197 Added alpine and debian dockerfiles 2020-08-03 19:45:10 +01:00
Harvey Tindall
54af15cc5a Added makefile
Alternative to goreleaser.
2020-08-03 18:00:54 +01:00
Harvey Tindall
8ed1662a2f add pprof middleware 2020-08-03 00:13:09 +01:00
Harvey Tindall
23dbcf33ae reinitialize validator on settings change 2020-08-03 00:12:45 +01:00
Harvey Tindall
25348a9b1a ignore binary 2020-08-03 00:11:06 +01:00
Harvey Tindall
3970cbef3f remove smtp notice 2020-08-02 17:25:33 +01:00
Harvey Tindall
a38d56f362 add smtp email 2020-08-02 17:20:50 +01:00
Harvey Tindall
f0be006e16 use goroutines for (most) emails
invite emails have been left alone so that email success message is
shown on web ui
2020-08-02 17:17:29 +01:00
Harvey Tindall
699489e435 fixed static route for invites 2020-08-02 17:16:43 +01:00
Harvey Tindall
e576616530 convert text to path to fix rendering on mobile 2020-08-02 13:16:43 +01:00
Harvey Tindall
05c7b7156b Add notice about smtp 2020-08-02 13:13:33 +01:00
Harvey Tindall
c72e1a1c63 Images, fixed "data" path 2020-08-02 02:11:50 +01:00
40 changed files with 2601 additions and 707 deletions

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ data/config-default.ini
data/*.html
data/*.txt
dist/*
jfa-go
build/
pkg/
old/

View File

@@ -5,7 +5,7 @@ release:
github:
owner: hrfee
name: jfa-go
name_template: "v{{.Version}} {{.Env.USER}}"
name_template: "v{{.Version}}"
before:
hooks:
# You may remove this if you don't use go modules.
@@ -14,8 +14,8 @@ before:
- python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 -m pip install libsass
- python3 scss/get_node_deps.py
- python3 scss/compile.py
- python3 mail/generate.py
- python3 scss/compile.py -y
- python3 mail/generate.py -y
builds:
- dir: ./
env:
@@ -24,19 +24,22 @@ builds:
- linux
- windows
- darwin
goarch:
- amd64
- arm
- arm64
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
files:
- data/*
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
name_template: "{{ .Tag }}-testing"
changelog:
sort: asc
filters:

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:latest AS build
COPY . /opt/build
RUN apt update -y \
&& apt install build-essential python3-pip curl software-properties-common sed -y \
&& (curl -sL https://deb.nodesource.com/setup_14.x | bash -) \
&& apt install nodejs \
&& (cd /opt/build; make headless) \
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/templates/setup.html
FROM golang:latest
COPY --from=build /opt/build/build /opt/jfa-go
EXPOSE 8056
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]

21
LICENSE Normal file
View File

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

49
Makefile Normal file
View File

@@ -0,0 +1,49 @@
configuration:
echo "Fixing config-base"
python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
echo "Generating config-default.ini"
python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
sass:
echo "Getting libsass"
python3 -m pip install libsass
echo "Getting node dependencies"
python3 scss/get_node_deps.py
echo "Compiling sass"
python3 scss/compile.py
sass-headless:
echo "Getting libsass"
python3 -m pip install libsass
echo "Getting node dependencies"
python3 scss/get_node_deps.py
echo "Compiling sass"
python3 scss/compile.py -y
mail-headless:
echo "Generating email html"
python3 mail/generate.py -y
mail:
echo "Generating email html"
python3 mail/generate.py
compile:
echo "Downloading deps"
go mod download
echo "Building"
mkdir -p build
go build -o build/jfa-go *.go
copy:
echo "Copying data"
cp -r data build/
install:
cp -r build $(DESTDIR)/jfa-go
all: configuration sass mail compile copy
headless: configuration sass-headless mail-headless copy

View File

@@ -1,11 +1,83 @@
# jfa-go
# ![jfa-go](images/jfa-go-banner-wide.svg)
A rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) in Go. Should be fully functional, and functions the same as jf-accounts. To switch, copy your existing `~/.jf-accounts` to:
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) in Go mainly as a learning experience, but also to slightly improve speeds and efficiency.
#### Features
* 🧑 Invite based account creation: Sends invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account defaults: Configure an example account to your liking, and its permissions, access rights and homescreen layout can be applied to all new users.
* Password validation: Ensure users choose a strong password.
* 📨 Email storage: Add your existing user's email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When user's forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email.
* Notifications: Get notified when someone creates an account, or an invite expires.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizable look
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* Optionally provide custom CSS
## Interface
<p align="center">
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/jfa.gif" width="100%"></img>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
</p>
#### Install
Available on the AUR as [jfa-go](https://aur.archlinux.org/packages/jfa-go/).
For other platforms, grab an archive from the release section for your platform, and extract `jfa-go` and `data` to the same directory.
* For linux users, you can place them inside `/opt/jfa-go` and then run
`sudo ln -s /opt/jfa-go/jfa-go /usr/bin/jfa-go` to place it in your PATH.
Run the executable to start.
For [docker](https://hub.docker.com/repository/docker/hrfee/jfa-go), run:
```
docker create \
--name "jfa-go" \ # Whatever you want to name it
-p 8056:8056 \
-v /path/to/.config/jfa-go:/data \ # Equivalent of ~/.jf-accounts
-v /path/to/jellyfin:/jf \ # Path to jellyfin config directory
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go
```
#### Build from source
A Dockerfile is provided that creates an image built from source, but it's only suitable for those who will run jfa-go in docker.
Full build instructions can be found [here](https://github.com/hrfee/jfa-go/wiki/Build).
#### Usage
Simply run `jfa-go` to start the application. A setup wizard will start on `localhost:8056` (or your own specified address). Upon completion, refresh the page.
Note: jfa-go does not run as a daemon by default. You'll need to figure this out yourself.
```
Usage of ./jfa-go:
-config string
alternate path to config file. (default "~/.config/jfa-go/config.ini")
-data string
alternate path to data directory. (default "~/.config/jfa-go")
-host string
alternate address to host web ui on.
-port int
alternate port to host web ui on.
```
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(*or specify config/data path with `-config/-data` respectively.*)
Suggestions and help welcome.

380
api.go
View File

@@ -2,33 +2,34 @@ package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/knz/strtime"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
"os"
//"os/exec"
"syscall"
"time"
)
func (ctx *appContext) loadStrftime() {
ctx.datePattern = ctx.config.Section("email").Key("date_format").String()
ctx.timePattern = `%H:%M`
if val, _ := ctx.config.Section("email").Key("use_24h").Bool(); !val {
ctx.timePattern = `%I:%M %p`
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 (ctx *appContext) prettyTime(dt time.Time) (date, time string) {
date, _ = strtime.Strftime(dt, ctx.datePattern)
time, _ = strtime.Strftime(dt, ctx.timePattern)
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
date, _ = strtime.Strftime(dt, app.datePattern)
time, _ = strtime.Strftime(dt, app.timePattern)
return
}
func (ctx *appContext) formatDatetime(dt time.Time) string {
d, t := ctx.prettyTime(dt)
func (app *appContext) formatDatetime(dt time.Time) string {
d, t := app.prettyTime(dt)
return d + " " + t
}
@@ -79,82 +80,86 @@ func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) {
return
}
func (ctx *appContext) checkInvites() {
func (app *appContext) checkInvites() {
current_time := time.Now()
ctx.storage.loadInvites()
app.storage.loadInvites()
changed := false
for code, data := range ctx.storage.invites {
for code, data := range app.storage.invites {
expiry := data.ValidTill
if current_time.After(expiry) {
ctx.debug.Printf("Housekeeping: Deleting old invite %s", code)
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := data.Notify
if ctx.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
ctx.debug.Printf("%s: Expiry notification", code)
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"] {
if ctx.email.constructExpiry(code, data, ctx) != nil {
ctx.err.Printf("%s: Failed to construct expiry notification", code)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send expiry notification", code)
} else {
ctx.info.Printf("Sent expiry notification to %s", address)
}
go func() {
if app.email.constructExpiry(code, data, app) != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil {
app.err.Printf("%s: Failed to send expiry notification", code)
} else {
app.info.Printf("Sent expiry notification to %s", address)
}
}()
}
}
}
changed = true
delete(ctx.storage.invites, code)
delete(app.storage.invites, code)
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
}
func (ctx *appContext) checkInvite(code string, used bool, username string) bool {
func (app *appContext) checkInvite(code string, used bool, username string) bool {
current_time := time.Now()
ctx.storage.loadInvites()
app.storage.loadInvites()
changed := false
if inv, match := ctx.storage.invites[code]; match {
if inv, match := app.storage.invites[code]; match {
expiry := inv.ValidTill
if current_time.After(expiry) {
ctx.debug.Printf("Housekeeping: Deleting old invite %s", code)
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if ctx.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
ctx.debug.Printf("%s: Expiry notification", code)
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"] {
if ctx.email.constructExpiry(code, inv, ctx) != nil {
ctx.err.Printf("%s: Failed to construct expiry notification", code)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send expiry notification", code)
} else {
ctx.info.Printf("Sent expiry notification to %s", address)
}
go func() {
if app.email.constructExpiry(code, inv, app) != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil {
app.err.Printf("%s: Failed to send expiry notification", code)
} else {
app.info.Printf("Sent expiry notification to %s", address)
}
}()
}
}
}
changed = true
match = false
delete(ctx.storage.invites, code)
delete(app.storage.invites, code)
} else if used {
changed = true
del := false
newInv := inv
if newInv.RemainingUses == 1 {
del = true
delete(ctx.storage.invites, code)
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, ctx.formatDatetime(current_time)})
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(current_time)})
if !del {
ctx.storage.invites[code] = newInv
app.storage.invites[code] = newInv
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
return match
}
@@ -170,17 +175,17 @@ type newUserReq struct {
Code string `json:"code"`
}
func (ctx *appContext) NewUser(gc *gin.Context) {
func (app *appContext) NewUser(gc *gin.Context) {
var req newUserReq
gc.BindJSON(&req)
ctx.debug.Printf("%s: New user attempt", req.Code)
if !ctx.checkInvite(req.Code, false, "") {
ctx.info.Printf("%s New user failed: invalid code", req.Code)
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)
gc.JSON(401, map[string]bool{"success": false})
gc.Abort()
return
}
validation := ctx.validator.validate(req.Password)
validation := app.validator.validate(req.Password)
valid := true
for _, val := range validation {
if !val {
@@ -189,38 +194,40 @@ func (ctx *appContext) NewUser(gc *gin.Context) {
}
if !valid {
// 200 bcs idk what i did in js
ctx.info.Printf("%s New user failed: Invalid password", req.Code)
app.info.Printf("%s New user failed: Invalid password", req.Code)
gc.JSON(200, validation)
gc.Abort()
return
}
existingUser, _, _ := ctx.jf.userByName(req.Username, false)
existingUser, _, _ := app.jf.userByName(req.Username, false)
if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username)
ctx.info.Printf("%s New user failed: %s", req.Code, msg)
app.info.Printf("%s New user failed: %s", req.Code, msg)
respond(401, msg, gc)
return
}
user, status, err := ctx.jf.newUser(req.Username, req.Password)
user, status, err := app.jf.newUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
respond(401, "Unknown error", gc)
return
}
ctx.checkInvite(req.Code, true, req.Username)
invite := ctx.storage.invites[req.Code]
if ctx.config.Section("notifications").Key("enabled").MustBool(false) {
app.checkInvite(req.Code, true, req.Username)
invite := app.storage.invites[req.Code]
if app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify {
if settings["notify-creation"] {
if ctx.email.constructCreated(req.Code, req.Username, req.Email, invite, ctx) != nil {
ctx.err.Printf("%s: Failed to construct user creation notification", req.Code)
ctx.debug.Printf("%s: Error: %s", req.Code, err)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send user creation notification", req.Code)
ctx.debug.Printf("%s: Error: %s", req.Code, err)
} else {
ctx.info.Printf("%s: Sent user creation notification to %s", req.Code, address)
}
go func() {
if app.email.constructCreated(req.Code, req.Username, req.Email, invite, app) != nil {
app.err.Printf("%s: Failed to construct user creation notification", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err)
} else if app.email.send(address, app) != 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)
}
}()
}
}
}
@@ -228,23 +235,23 @@ func (ctx *appContext) NewUser(gc *gin.Context) {
if user["Id"] != nil {
id = user["Id"].(string)
}
if len(ctx.storage.policy) != 0 {
status, err = ctx.jf.setPolicy(id, ctx.storage.policy)
if len(app.storage.policy) != 0 {
status, err = app.jf.setPolicy(id, app.storage.policy)
if !(status == 200 || status == 204) {
ctx.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
}
}
if len(ctx.storage.configuration) != 0 && len(ctx.storage.displayprefs) != 0 {
status, err = ctx.jf.setConfiguration(id, ctx.storage.configuration)
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 = ctx.jf.setDisplayPreferences(id, ctx.storage.displayprefs)
status, err = app.jf.setDisplayPreferences(id, app.storage.displayprefs)
} else {
ctx.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
}
}
if ctx.config.Section("password_resets").Key("enabled").MustBool(false) {
ctx.storage.emails[id] = req.Email
ctx.storage.storeEmails()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email
app.storage.storeEmails()
}
gc.JSON(200, validation)
}
@@ -259,10 +266,10 @@ type generateInviteReq struct {
RemainingUses int `json:"remaining-uses"`
}
func (ctx *appContext) GenerateInvite(gc *gin.Context) {
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteReq
ctx.debug.Println("Generating new invite")
ctx.storage.loadInvites()
app.debug.Println("Generating new invite")
app.storage.loadInvites()
gc.BindJSON(&req)
current_time := time.Now()
valid_till := current_time.AddDate(0, 0, req.Days)
@@ -280,40 +287,40 @@ func (ctx *appContext) GenerateInvite(gc *gin.Context) {
invite.RemainingUses = 1
}
invite.ValidTill = valid_till
if req.Email != "" && ctx.config.Section("invite_emails").Key("enabled").MustBool(false) {
ctx.debug.Printf("%s: Sending invite email", invite_code)
if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", invite_code)
invite.Email = req.Email
if err := ctx.email.constructInvite(invite_code, invite, ctx); err != nil {
if err := app.email.constructInvite(invite_code, invite, app); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
ctx.err.Printf("%s: Failed to construct invite email", invite_code)
ctx.debug.Printf("%s: Error: %s", invite_code, err)
} else if err := ctx.email.send(req.Email, ctx); err != nil {
app.err.Printf("%s: Failed to construct invite email", invite_code)
app.debug.Printf("%s: Error: %s", invite_code, err)
} else if err := app.email.send(req.Email, app); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
ctx.err.Printf("%s: %s", invite_code, invite.Email)
ctx.debug.Printf("%s: Error: %s", invite_code, err)
app.err.Printf("%s: %s", invite_code, invite.Email)
app.debug.Printf("%s: Error: %s", invite_code, err)
} else {
ctx.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
}
}
ctx.storage.invites[invite_code] = invite
ctx.storage.storeInvites()
app.storage.invites[invite_code] = invite
app.storage.storeInvites()
gc.JSON(200, map[string]bool{"success": true})
}
func (ctx *appContext) GetInvites(gc *gin.Context) {
ctx.debug.Println("Invites requested")
func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
current_time := time.Now()
ctx.storage.loadInvites()
ctx.checkInvites()
app.storage.loadInvites()
app.checkInvites()
var invites []map[string]interface{}
for code, inv := range ctx.storage.invites {
for code, inv := range app.storage.invites {
_, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, current_time)
invite := make(map[string]interface{})
invite["code"] = code
invite["days"] = days
invite["hours"] = hours
invite["minutes"] = minutes
invite["created"] = ctx.formatDatetime(inv.Created)
invite["created"] = app.formatDatetime(inv.Created)
if len(inv.UsedBy) != 0 {
invite["used-by"] = inv.UsedBy
}
@@ -329,11 +336,11 @@ func (ctx *appContext) GetInvites(gc *gin.Context) {
}
if len(inv.Notify) != 0 {
var address string
if ctx.config.Section("ui").Key("jellyfin_login").MustBool(false) {
ctx.storage.loadEmails()
address = ctx.storage.emails[gc.GetString("jfId")].(string)
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
app.storage.loadEmails()
address = app.storage.emails[gc.GetString("jfId")].(string)
} else {
address = ctx.config.Section("ui").Key("email").String()
address = app.config.Section("ui").Key("email").String()
}
if _, ok := inv.Notify[address]; ok {
for _, notify_type := range []string{"notify-expiry", "notify-creation"} {
@@ -356,34 +363,34 @@ type notifySetting struct {
NotifyCreation bool `json:"notify-creation"`
}
func (ctx *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) SetNotify(gc *gin.Context) {
var req map[string]notifySetting
gc.BindJSON(&req)
changed := false
for code, settings := range req {
ctx.debug.Printf("%s: Notification settings change requested", code)
ctx.storage.loadInvites()
ctx.storage.loadEmails()
invite, ok := ctx.storage.invites[code]
app.debug.Printf("%s: Notification settings change requested", code)
app.storage.loadInvites()
app.storage.loadEmails()
invite, ok := app.storage.invites[code]
if !ok {
ctx.err.Printf("%s Notification setting change failed: Invalid code", code)
app.err.Printf("%s Notification setting change failed: Invalid code", code)
gc.JSON(400, map[string]string{"error": "Invalid invite code"})
gc.Abort()
return
}
var address string
if ctx.config.Section("ui").Key("jellyfin_login").MustBool(false) {
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
var ok bool
address, ok = ctx.storage.emails[gc.GetString("jfId")].(string)
address, ok = app.storage.emails[gc.GetString("jfId")].(string)
if !ok {
ctx.err.Printf("%s: Couldn't find email address. Make sure it's set", code)
ctx.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
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"))
gc.JSON(500, map[string]string{"error": "Missing user email"})
gc.Abort()
return
}
} else {
address = ctx.config.Section("ui").Key("email").String()
address = app.config.Section("ui").Key("email").String()
}
if invite.Notify == nil {
invite.Notify = map[string]map[string]bool{}
@@ -395,20 +402,20 @@ func (ctx *appContext) SetNotify(gc *gin.Context) {
*/
if invite.Notify[address]["notify-expiry"] != settings.NotifyExpiry {
invite.Notify[address]["notify-expiry"] = settings.NotifyExpiry
ctx.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings.NotifyExpiry, address)
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings.NotifyExpiry, address)
changed = true
}
if invite.Notify[address]["notify-creation"] != settings.NotifyCreation {
invite.Notify[address]["notify-creation"] = settings.NotifyCreation
ctx.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings.NotifyExpiry, address)
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings.NotifyExpiry, address)
changed = true
}
if changed {
ctx.storage.invites[code] = invite
app.storage.invites[code] = invite
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
}
@@ -416,20 +423,20 @@ type deleteReq struct {
Code string `json:"code"`
}
func (ctx *appContext) DeleteInvite(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteReq
gc.BindJSON(&req)
ctx.debug.Printf("%s: Deletion requested", req.Code)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = ctx.storage.invites[req.Code]
_, ok = app.storage.invites[req.Code]
if ok {
delete(ctx.storage.invites, req.Code)
ctx.storage.storeInvites()
ctx.info.Printf("%s: Invite deleted", req.Code)
delete(app.storage.invites, req.Code)
app.storage.storeInvites()
app.info.Printf("%s: Invite deleted", req.Code)
gc.JSON(200, map[string]bool{"success": true})
return
}
ctx.err.Printf("%s: Deletion failed: Invalid code", req.Code)
app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
respond(401, "Code doesn't exist", gc)
}
@@ -442,21 +449,21 @@ type respUser struct {
Email string `json:"email,omitempty"`
}
func (ctx *appContext) GetUsers(gc *gin.Context) {
ctx.debug.Println("Users requested")
func (app *appContext) GetUsers(gc *gin.Context) {
app.debug.Println("Users requested")
var resp userResp
resp.UserList = []respUser{}
users, status, err := ctx.jf.getUsers(false)
users, status, err := app.jf.getUsers(false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
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.Name = jfUser["Name"].(string)
if email, ok := ctx.storage.emails[jfUser["Id"].(string)]; ok {
if email, ok := app.storage.emails[jfUser["Id"].(string)]; ok {
user.Email = email.(string)
}
resp.UserList = append(resp.UserList, user)
@@ -464,24 +471,24 @@ func (ctx *appContext) GetUsers(gc *gin.Context) {
gc.JSON(200, resp)
}
func (ctx *appContext) ModifyEmails(gc *gin.Context) {
func (app *appContext) ModifyEmails(gc *gin.Context) {
var req map[string]string
gc.BindJSON(&req)
ctx.debug.Println("Email modification requested")
users, status, err := ctx.jf.getUsers(false)
app.debug.Println("Email modification requested")
users, status, err := app.jf.getUsers(false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
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 {
if address, ok := req[jfUser["Name"].(string)]; ok {
ctx.storage.emails[jfUser["Id"].(string)] = address
app.storage.emails[jfUser["Id"].(string)] = address
}
}
ctx.storage.storeEmails()
ctx.info.Println("Email list modified")
app.storage.storeEmails()
app.info.Println("Email list modified")
gc.JSON(200, map[string]bool{"success": true})
}
@@ -490,46 +497,46 @@ type defaultsReq struct {
Homescreen bool `json:"homescreen"`
}
func (ctx *appContext) SetDefaults(gc *gin.Context) {
func (app *appContext) SetDefaults(gc *gin.Context) {
var req defaultsReq
gc.BindJSON(&req)
ctx.info.Printf("Getting user defaults from \"%s\"", req.Username)
user, status, err := ctx.jf.userByName(req.Username, false)
app.info.Printf("Getting user defaults from \"%s\"", req.Username)
user, status, err := app.jf.userByName(req.Username, false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get user from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
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
}
userId := user["Id"].(string)
policy := user["Policy"].(map[string]interface{})
ctx.storage.policy = policy
ctx.storage.storePolicy()
ctx.debug.Println("User policy template stored")
app.storage.policy = policy
app.storage.storePolicy()
app.debug.Println("User policy template stored")
if req.Homescreen {
configuration := user["Configuration"].(map[string]interface{})
var displayprefs map[string]interface{}
displayprefs, status, err = ctx.jf.getDisplayPreferences(userId)
displayprefs, status, err = app.jf.getDisplayPreferences(userId)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get DisplayPrefs: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get DisplayPrefs: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get displayprefs", gc)
return
}
ctx.storage.configuration = configuration
ctx.storage.displayprefs = displayprefs
ctx.storage.storeConfiguration()
ctx.debug.Println("Configuration template stored")
ctx.storage.storeDisplayprefs()
ctx.debug.Println("DisplayPrefs template stored")
app.storage.configuration = configuration
app.storage.displayprefs = displayprefs
app.storage.storeConfiguration()
app.debug.Println("Configuration template stored")
app.storage.storeDisplayprefs()
app.debug.Println("DisplayPrefs template stored")
}
gc.JSON(200, map[string]bool{"success": true})
}
func (ctx *appContext) GetConfig(gc *gin.Context) {
ctx.info.Println("Config requested")
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := map[string]interface{}{}
for section, settings := range ctx.configBase {
for section, settings := range app.configBase {
if section == "order" {
resp[section] = settings.([]interface{})
} else {
@@ -541,7 +548,7 @@ func (ctx *appContext) GetConfig(gc *gin.Context) {
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 := ctx.config.Section(section).Key(key)
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
@@ -559,11 +566,11 @@ func (ctx *appContext) GetConfig(gc *gin.Context) {
gc.JSON(200, resp)
}
func (ctx *appContext) ModifyConfig(gc *gin.Context) {
ctx.info.Println("Config modification requested")
func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req map[string]interface{}
gc.BindJSON(&req)
tempConfig, _ := ini.Load(ctx.config_path)
tempConfig, _ := ini.Load(app.config_path)
for section, settings := range req {
_, err := tempConfig.GetSection(section)
if section != "restart-program" && err == nil {
@@ -572,17 +579,46 @@ func (ctx *appContext) ModifyConfig(gc *gin.Context) {
}
}
}
tempConfig.SaveTo(ctx.config_path)
ctx.debug.Println("Config saved")
tempConfig.SaveTo(app.config_path)
app.debug.Println("Config saved")
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"].(bool) {
ctx.info.Println("Restarting...")
err := Restart()
app.info.Println("Restarting...")
err := app.Restart()
if err != nil {
ctx.err.Printf("Couldn't restart, try restarting manually. (%s)", err)
app.err.Printf("Couldn't restart, try restarting manually. (%s)", err)
}
}
ctx.loadConfig()
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)
}
}
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)
gc.JSON(200, map[string]bool{"success": true})
}
// func Restart() error {
@@ -611,10 +647,11 @@ func (ctx *appContext) ModifyConfig(gc *gin.Context) {
// panic(fmt.Errorf("restarting"))
// }
func Restart() error {
func (app *appContext) Restart() error {
defer func() {
if r := recover(); r != nil {
os.Exit(0)
signal.Notify(app.quit, os.Interrupt)
<-app.quit
}
}()
args := os.Args
@@ -625,7 +662,6 @@ func Restart() error {
os.Setenv("JFA_EXEC", args[0])
}
env := os.Environ()
fmt.Printf("EXECUTABLE: %s\n", os.Getenv("JFA_EXEC"))
err := syscall.Exec(os.Getenv("JFA_EXEC"), []string{""}, env)
if err != nil {
return err

339
auth.go
View File

@@ -3,147 +3,244 @@ 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"
"os"
"strings"
"time"
)
func (ctx *appContext) webAuth() gin.HandlerFunc {
return ctx.authenticate
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
func (ctx *appContext) authenticate(gc *gin.Context) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Basic" {
ctx.debug.Println("Invalid authentication header")
respond(401, "Unauthorized", gc)
return
}
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
token, err := jwt.Parse(creds[0], func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
ctx.debug.Printf("Invalid JWT signing method %s", token.Header["alg"])
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
}
return []byte(os.Getenv("JFA_SECRET")), nil
})
if err != nil {
ctx.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
var userId string
var jfId string
if ok && token.Valid {
userId = claims["id"].(string)
jfId = claims["jfid"].(string)
} else {
ctx.debug.Printf("Invalid token")
respond(401, "Unauthorized", gc)
return
}
match := false
for _, user := range ctx.users {
if user.UserID == userId {
match = true
}
}
if !match {
ctx.debug.Printf("Couldn't find user ID %s", userId)
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfId)
gc.Set("userId", userId)
ctx.debug.Println("Authentication successful")
gc.Next()
}
func (ctx *appContext) GetToken(gc *gin.Context) {
ctx.info.Println("Token requested (login attempt)")
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Basic" {
ctx.debug.Println("Invalid authentication header")
respond(401, "Unauthorized", gc)
return
}
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
match := false
var userId string
for _, user := range ctx.users {
if user.Username == creds[0] && user.Password == creds[1] {
match = true
userId = user.UserID
}
}
jfId := ""
if !match {
if !ctx.jellyfinLogin {
ctx.info.Println("Auth failed: Invalid username and/or password")
respond(401, "Unauthorized", gc)
return
}
var status int
var err error
var user map[string]interface{}
user, status, err = ctx.authJf.authenticate(creds[0], creds[1])
jfId = user["Id"].(string)
if status != 200 || err != nil {
if status == 401 {
ctx.info.Println("Auth failed: Invalid username and/or password")
respond(401, "Unauthorized", gc)
return
}
ctx.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status)
respond(500, "Jellyfin error", gc)
return
} else {
if ctx.config.Section("ui").Key("admin_only").MustBool(true) {
if !user["Policy"].(map[string]interface{})["IsAdministrator"].(bool) {
ctx.debug.Printf("Auth failed: User \"%s\" isn't admin", creds[0])
respond(401, "Unauthorized", gc)
}
}
newuser := User{}
newuser.UserID = shortuuid.New()
userId = newuser.UserID
// uuid, nothing else identifiable!
ctx.debug.Printf("Token generated for user \"%s\"", creds[0])
ctx.users = append(ctx.users, newuser)
}
}
token, err := CreateToken(userId, jfId)
if err != nil {
respond(500, "Error generating token", gc)
}
resp := map[string]string{"token": token}
gc.JSON(200, resp)
}
func CreateToken(userId string, jfId string) (string, error) {
// 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": time.Now().Add(time.Minute * 20).Unix(),
"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
return "", "", err
}
return token, nil
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
}
func respond(code int, message string, gc *gin.Context) {
resp := map[string]string{"error": message}
resp := map[string]string{}
if code == 200 || code == 204 {
resp["response"] = message
} else {
resp["error"] = message
}
gc.JSON(code, resp)
gc.Abort()
}
// Check header for token
func (app *appContext) authenticate(gc *gin.Context) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Basic" {
app.debug.Println("Invalid authentication header")
respond(401, "Unauthorized", gc)
return
}
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
token, err := jwt.Parse(creds[0], 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
}
// getToken checks the header for a username and password, as well as checking the refresh cookie.
func (app *appContext) getToken(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)
// check cookie first
var userID, jfID string
valid := false
noLogin := false
checkLogin := func() {
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)
}
valid = true
}
checkCookie := func() {
cookie, err := gc.Cookie("refresh")
if err == nil && cookie != "" {
for _, token := range app.invalidTokens {
if cookie == token {
if creds[0] == "" || creds[1] == "" {
app.debug.Println("getToken denied: Invalid refresh token and no username/password provided")
respond(401, "Unauthorized", gc)
noLogin = true
return
}
app.debug.Println("getToken: Invalid token but username/password provided")
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
if creds[0] == "" || creds[1] == "" {
app.debug.Println("getToken denied: Invalid refresh token and no username/password provided")
respond(401, "Unauthorized", gc)
noLogin = true
return
}
app.debug.Println("getToken: Invalid token but username/password provided")
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64)
if err != nil {
if creds[0] == "" || creds[1] == "" {
app.debug.Printf("getToken denied: Invalid token (%s) and no username/password provided", err)
respond(401, "Unauthorized", gc)
noLogin = true
return
}
app.debug.Printf("getToken: Invalid token (%s) but username/password provided", err)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
if creds[0] == "" || creds[1] == "" {
app.debug.Printf("getToken denied: Invalid token (%s) and no username/password provided", err)
respond(401, "Unauthorized", gc)
noLogin = true
return
}
app.debug.Printf("getToken: Invalid token (%s) but username/password provided", err)
return
}
userID = claims["id"].(string)
jfID = claims["jfid"].(string)
valid = true
}
}
checkCookie()
if !valid && !noLogin {
checkLogin()
}
if valid {
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, map[string]string{"token": token})
} else {
gc.AbortWithStatus(401)
}
}

View File

@@ -1,46 +1,73 @@
package main
import (
"gopkg.in/ini.v1"
"path/filepath"
"strconv"
"gopkg.in/ini.v1"
)
func (ctx *appContext) loadConfig() error {
/*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
ctx.config, err = ini.Load(ctx.config_path)
app.config, err = ini.Load(app.config_path)
if err != nil {
return err
}
ctx.config.Section("jellyfin").Key("public_server").SetValue(ctx.config.Section("jellyfin").Key("public_server").MustString(ctx.config.Section("jellyfin").Key("server").String()))
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 ctx.config.Section("files").Keys() {
for _, key := range app.config.Section("files").Keys() {
// if key.MustString("") == "" && key.Name() != "custom_css" {
// key.SetValue(filepath.Join(ctx.data_path, (key.Name() + ".json")))
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
key.SetValue(key.MustString(filepath.Join(ctx.data_path, (key.Name() + ".json"))))
key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json"))))
}
for _, key := range []string{"user_configuration", "user_displayprefs"} {
// if ctx.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(ctx.data_path, (key.Name() + ".json")))
// if app.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
ctx.config.Section("files").Key(key).SetValue(ctx.config.Section("files").Key(key).MustString(filepath.Join(ctx.data_path, (key + ".json"))))
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.data_path, (key + ".json"))))
}
ctx.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(ctx.config.Section("email").Key("no_username").MustBool(false)))
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
ctx.config.Section("password_resets").Key("email_html").SetValue(ctx.config.Section("password_resets").Key("email_html").MustString(filepath.Join(ctx.local_path, "email.html")))
ctx.config.Section("password_resets").Key("email_text").SetValue(ctx.config.Section("password_resets").Key("email_text").MustString(filepath.Join(ctx.local_path, "email.txt")))
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.local_path, "email.html")))
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.local_path, "email.txt")))
ctx.config.Section("invite_emails").Key("email_html").SetValue(ctx.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(ctx.local_path, "invite-email.html")))
ctx.config.Section("invite_emails").Key("email_text").SetValue(ctx.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(ctx.local_path, "invite-email.txt")))
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.local_path, "invite-email.html")))
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.local_path, "invite-email.txt")))
ctx.config.Section("notifications").Key("expiry_html").SetValue(ctx.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(ctx.local_path, "expired.html")))
ctx.config.Section("notifications").Key("expiry_text").SetValue(ctx.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(ctx.local_path, "expired.txt")))
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.local_path, "expired.html")))
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.local_path, "expired.txt")))
ctx.config.Section("notifications").Key("created_html").SetValue(ctx.config.Section("notifications").Key("created_html").MustString(filepath.Join(ctx.local_path, "created.html")))
ctx.config.Section("notifications").Key("created_text").SetValue(ctx.config.Section("notifications").Key("created_text").MustString(filepath.Join(ctx.local_path, "created.txt")))
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
return nil
}

View File

@@ -1,5 +1,11 @@
## fixconfig
### 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.
Place the config base at `./config-base.json`, run `python fixconfig.py`, and the new config base will be stored at `./ordered-config-base.json`.
Specify the input and output files with `-i` and `-o` respectively.
### jsontostruct
Generates a go struct from `config-base.json`. I wrote this because i was annoyed with the `ini` library, but i've since realised mapping the ini values onto it is painful.

View File

@@ -147,7 +147,8 @@
"required": false,
"requires_restart": true,
"type": "bool",
"value": false
"value": false,
"description": "Enables debug logging and exposes pprof as a route (Don't use in production!)"
},
"contact_message": {
"name": "Contact message",

541
config/configStruct.go Normal file
View File

@@ -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"`
}

57
config/jsontostruct.py Normal file
View File

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

View File

@@ -9,21 +9,21 @@ type Repeater struct {
ShutdownChannel chan string
Interval time.Duration
period time.Duration
ctx *appContext
app *appContext
}
func NewRepeater(interval time.Duration, ctx *appContext) *Repeater {
func NewRepeater(interval time.Duration, app *appContext) *Repeater {
return &Repeater{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
ctx: ctx,
app: app,
}
}
func (rt *Repeater) Run() {
rt.ctx.info.Println("Invite daemon started")
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
@@ -33,9 +33,9 @@ func (rt *Repeater) Run() {
break
}
started := time.Now()
rt.ctx.storage.loadInvites()
rt.ctx.debug.Println("Daemon: Checking invites")
rt.ctx.checkInvites()
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

View File

@@ -184,7 +184,8 @@
"required": false,
"requires_restart": true,
"type": "bool",
"value": false
"value": false,
"description": "Enables debug logging and exposes pprof as a route (Don't use in production!)"
},
"contact_message": {
"name": "Contact message",

View File

@@ -19,14 +19,25 @@ var transitionEndEvent = whichTransitionEvent();
// Toggles between light and dark themes
function toggleCSS() {
let cssEl = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]')[0];
let Els = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]');
let cssEl = Els[0]
let remove = false;
if (Els.length != 1) {
cssEl = Els[1]
remove = true
}
let href = "bs" + bsVersion;
if (cssEl.href.includes(href + "-jf")) {
href += ".css";
} else {
href += "-jf.css";
}
cssEl.href = href
let newEl = cssEl.cloneNode(true);
newEl.href = href
cssEl.parentNode.insertBefore(newEl, cssEl.nextSibling);
if (remove) {
Els[0].remove()
}
document.cookie = "css=" + href;
}
@@ -136,8 +147,8 @@ function addItem(parsedInvite) {
let code = document.createElement('div');
code.classList.add('d-flex', 'align-items-center', 'font-monospace');
code.setAttribute('style', 'width: 40%;');
let codeLink = document.createElement('a');
codeLink.setAttribute('style', 'margin-right: 0.5rem;');
codeLink.textContent = parsedInvite[0].replace(/-/g, '-');
code.appendChild(codeLink);
@@ -145,6 +156,7 @@ function addItem(parsedInvite) {
listItem.appendChild(code);
let listRight = document.createElement('div');
listRight.setAttribute('style', 'text-align: right;');
let listText = document.createElement('span');
listText.id = parsedInvite[0] + '_expiry';
listText.setAttribute('style', 'margin-right: 1rem;');
@@ -156,9 +168,11 @@ function addItem(parsedInvite) {
let inviteCode = window.location.href.split('#')[0] + 'invite/' + parsedInvite[0];
//
codeLink.href = inviteCode;
codeLink.classList.add('invite-link');
let copyButton = document.createElement('i');
copyButton.onclick = function() { toClipboard(inviteCode); };
copyButton.classList.add('fa', 'fa-clipboard', 'icon-button');
copyButton.setAttribute('style', 'margin-right: 0.5rem; margin-left: 0.5rem;');
code.appendChild(copyButton);
@@ -179,9 +193,12 @@ function addItem(parsedInvite) {
deleteButton.classList.add('btn', 'btn-outline-danger');
deleteButton.textContent = "Delete";
listRight.appendChild(deleteButton);
let block = document.createElement('div');
block.setAttribute('style', 'display: inline-block;');
block.appendChild(deleteButton);
let dropButton = document.createElement('i');
dropButton.classList.add('fa', 'fa-angle-down', 'collapsed', 'icon-button', 'not-rotated');
dropButton.setAttribute('style', 'padding: 1rem; margin: -1rem -1rem -1rem 0;');
dropButton.setAttribute('data-toggle', 'collapse');
dropButton.setAttribute('aria-expanded', 'false');
dropButton.setAttribute('data-target', '#' + CSS.escape(parsedInvite[0]) + '_collapse');
@@ -195,7 +212,8 @@ function addItem(parsedInvite) {
}
};
dropButton.setAttribute('style', 'margin-left: 1rem;');
listRight.appendChild(dropButton);
block.appendChild(dropButton);
listRight.appendChild(block);
}
listItem.appendChild(listRight);
@@ -513,28 +531,33 @@ document.getElementById('inviteForm').onsubmit = function() {
return false;
};
document.getElementById('loginForm').onsubmit = function() {
window.token = "";
let details = serializeForm('loginForm');
let errorArea = document.getElementById('loginErrorArea');
errorArea.textContent = '';
let button = document.getElementById('loginSubmit');
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
function tryLogin(username, password, modal, button, callback) {
let req = new XMLHttpRequest();
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 401) {
button.disabled = false;
button.textContent = 'Login';
let wrongPassword = document.createElement('div');
wrongPassword.classList.add('alert', 'alert-danger');
wrongPassword.setAttribute('role', 'alert');
wrongPassword.textContent = "Incorrect username or password.";
errorArea.appendChild(wrongPassword);
if (this.status != 200) {
let errormsg = req.response["error"];
if (errormsg == "") {
errormsg = "Unknown error"
}
if (modal) {
button.disabled = false;
button.textContent = errormsg;
if (!button.classList.contains('btn-danger')) {
button.classList.add('btn-danger');
button.classList.remove('btn-primary');
}
setTimeout(function () {
if (button.classList.contains('btn-danger')) {
button.classList.add('btn-primary');
button.classList.remove('btn-danger');
button.textContent = 'Login';
}
}, 4000)
} else {
loginModal.show();
}
} else {
const data = this.response;
window.token = data['token'];
@@ -549,13 +572,37 @@ document.getElementById('loginForm').onsubmit = function() {
let minutes = document.getElementById('minutes');
addOptions(59, minutes);
minutes.selected = "30";
loginModal.hide();
checkDuration();
if (modal) {
loginModal.hide();
}
document.getElementById('logoutButton').setAttribute('style', '');
}
if (typeof callback === "function") {
callback(this.status);
}
}
};
req.open("GET", "/getToken", true);
req.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
req.send();
}
document.getElementById('loginForm').onsubmit = function() {
window.token = "";
let details = serializeForm('loginForm');
// let errorArea = document.getElementById('loginErrorArea');
// errorArea.textContent = '';
let button = document.getElementById('loginSubmit');
if (button.classList.contains('btn-danger')) {
button.classList.add('btn-primary');
button.classList.remove('btn-danger');
}
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
tryLogin(username = details['username'], password = details['password'], modal = true, button = button)
return false;
};
@@ -693,10 +740,10 @@ document.getElementById('openUsers').onclick = function () {
address.setAttribute('type', 'email');
address.readOnly = true;
address.classList.add('form-control-plaintext', 'text-muted', 'd-inline-block', 'addressText');
address.id = 'address_' + user['email'];
address.id = 'address_' + user['name'];
address.setAttribute('style', 'width: auto; margin-left: 2%;');
if (typeof(user['email']) != 'undefined') {
address.value = user['email'];
address.setAttribute('style', 'width: auto; margin-left: 2%;');
}
let editButton = document.createElement('i');
editButton.classList.add('fa', 'fa-edit', 'd-inline-block', 'icon-button');
@@ -761,7 +808,28 @@ document.getElementById('openUsers').onclick = function () {
};
generateInvites(empty = true);
loginModal.show();
tryLogin("", "", false, callback = function(code){
console.log(code);
if (code != 200) {
loginModal.show();
}
});
document.getElementById('logoutButton').onclick = function () {
let req = new XMLHttpRequest();
req.open("POST", "/logout", true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
window.token = '';
location.reload();
return false;
}
};
req.send();
}
var config = {};
var modifiedConfig = {};
@@ -958,3 +1026,17 @@ document.getElementById('settingsSave').onclick = function() {
}
}
// Diable 'Generate' button if days, hours, minutes are all zero
function checkDuration() {
let boxVals = [document.getElementById("days").value, document.getElementById("hours").value, document.getElementById("minutes").value];
let submit = document.getElementById("generateSubmit");
if (boxVals[0] != 0 || boxVals[1] != 0 || boxVals[2] != 0) {
submit.disabled = false;
} else if (boxVals[0] == 0 && boxVals[1] == 0 && boxVals[2] == 0) {
submit.disabled = true;
}
}
for (i of ["days", "hours", "minutes"]) {
document.getElementById(i).addEventListener("change", checkDuration);
}

View File

@@ -1,3 +1,8 @@
document.getElementById('page-1').scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'center' });
function checkAuthRadio() {
if (document.getElementById('manualAuthRadio').checked) {
document.getElementById('adminOnlyArea').style.display = 'none';
@@ -99,36 +104,55 @@ var jfValid = false
document.getElementById('jfTestButton').onclick = function() {
var testButton = document.getElementById('jfTestButton');
var nextButton = document.getElementById('jfNextButton');
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var jfData = {};
jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value;
var req = new XMLHttpRequest();
req.open("POST", "/testJF", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
let valid = true;
for (val in jfData) {
if (jfData[val] == "") {
valid = false;
}
}
if (!valid) {
if (!testButton.classList.contains('btn-danger')) {
testButton.classList.add('btn-danger');
testButton.textContent = 'Fill out fields above.';
setTimeout(function() {
if (testButton.classList.contains('btn-danger')) {
testButton.classList.remove('btn-danger');
testButton.textContent = 'Test';
}
}, 2000);
}
} else {
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var req = new XMLHttpRequest();
req.open("POST", "/testJF", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
};
};
};
};
req.send(JSON.stringify(jfData));
};
req.send(JSON.stringify(jfData));
}
};
document.getElementById('submitButton').onclick = function() {
@@ -162,6 +186,10 @@ document.getElementById('submitButton').onclick = function() {
};
// Page 3: Connect to jellyfin
config['jellyfin']['server'] = document.getElementById('jfHost').value;
let publicAddress = document.getElementById('jfPublicHost').value;
if (publicAddress != "") {
config['jellyfin']['public_server'] = publicAddress;
}
config['jellyfin']['username'] = document.getElementById('jfUser').value;
config['jellyfin']['password'] = document.getElementById('jfPassword').value;
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)

View File

@@ -35,16 +35,22 @@
{{ else }}
const bsVersion = 4;
{{ end }}
const cssFile = "{{ .cssFile }}";
var cssFile = "{{ .cssFile }}";
var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) {
css.setAttribute('href', cssCookie);
} else {
css.setAttribute('href', cssFile);
};
cssFile = cssCookie;
} else if (cssCookie.includes('bs')) {
if (cssCookie.includes('jf')) {
cssFile = 'bs' + bsVersion + '-jf.css';
} else {
cssFile = 'bs' + bsVersion + '.css';
}
document.cookie = 'css=' + cssFile;
}
css.setAttribute('href', cssFile);
document.head.appendChild(css);
</script>
{{ if not .bs5 }}
@@ -117,6 +123,13 @@
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
</style>
<title>Admin</title>
</head>
@@ -136,7 +149,6 @@
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
</div>
</form>
<div id="loginErrorArea"></div>
</div>
<div class="modal-footer">
<button type="submit" id="loginSubmit" class="btn btn-primary" form="loginForm">Login</button>
@@ -252,6 +264,9 @@
<button type="button" class="btn btn-primary" id="openSettings">
Settings <i class="fa fa-cog"></i>
</button>
<button type="button" class="btn btn-danger" id="logoutButton" style="display: none;">
Logout <i class="fa fa-sign-out"></i>
</button>
</div>
<div class="card mb-3 linkGroup">
<div class="card-header">Current Invites</div>
@@ -264,7 +279,7 @@
<div class="card-body">
<form action="#" method="POST" id="inviteForm" class="container">
<div class="row align-items-start">
<div class="col">
<div class="col-sm">
<div class="form-group">
<label for="days">Days</label>
<select class="form-control form-select" id="days" name="days">

View File

@@ -3,12 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="bs5-jf.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
<style>
.card-body {
width: 100%;
@@ -45,7 +43,7 @@
<div class="card-body">
<h5 class="card-title">Welcome!</h5>
<p class="card-text">
You'll need to do a few things to start using jellyfin-accounts. Click below to get started, or quit and edit the config file manually.
You'll need to do a few things to start using jfa-go. Click below to get started, or quit and edit the config file manually.
</p>
<a class="btn btn-primary nextButton" href="#page-2">Get Started</a>
</div>
@@ -59,7 +57,7 @@
<p class="card-text">
To access the admin page, you'll need to login. Choose how below.
<ul>
<li><b>Authorize through Jellyfin: </b>Checks credentials with jellyfin, allowing you to share login details and grant multiple users access.</li>
<li><b>Authorize through Jellyfin: </b>Checks credentials with Jellyfin, allowing you to share login details and grant multiple users access.</li>
<li><b>Username & Password: </b>Set your own username and password manually.</li>
</ul>
<div class="form-check" id="jfAuthFormGroup">
@@ -106,31 +104,37 @@
<div class="card-body">
<h5 class="card-title">Jellyfin</h5>
<p class="card-text">
jellyfin-accounts needs admin access so that it can create users.
jfa-go needs admin access so that it can create users, as this is currently not permitted via API tokens.
You should create a separate account for it, checking 'Allow this user to manage the server'. You can disable everything else. Once done, enter the credentials here.
<div class="form-group">
<label for="jfHost">Host</label>
<input type="url" class="form-control" id="jfHost" placeholder="http://jellyf.in:443">
<label for="jfHost">Host (For internal use)</label>
<input type="url" class="form-control" id="jfHost" placeholder="http://jellyf.in:443" required>
</div>
<div class="form-group">
<label for="jfPublicHost">Public Host (For access by users)</label>
<input type="url" class="form-control" id="jfPublicHost" placeholder="Leave blank to use the above address.">
</div>
<div class="form-group">
<label for="jfUser">Username</label>
<input type="text" class="form-control" id="jfUser" placeholder="Username">
<input type="text" class="form-control" id="jfUser" placeholder="Username" required>
</div>
<div class="form-group">
<label for="jfPassword">Password</label>
<input type="password" class="form-control" id="jfPassword" placeholder="Password">
<input type="password" class="form-control" id="jfPassword" placeholder="Password" required>
</div>
<button class="btn btn-secondary" id="jfTestButton">Test</button>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-2">Back</a>
<a class="btn btn-primary nextButton disabled" id="jfNextButton" aria-disabled="true" href="#page-4">Next</a>
<div style="margin-top: 1rem;">
<button class="btn btn-secondary" id="jfTestButton">Test</button>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-2">Back</a>
<a class="btn btn-primary nextButton disabled" id="jfNextButton" aria-disabled="true" href="#page-4">Next</a>
</div>
</div>
</div>
</div>
<div class="slide card" id="page-4">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">jellyfin-accounts is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through <a href="https://www.mailgun.com/">Mailgun's</a> API.
<p class="card-text">jfa-go is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through <a href="https://www.mailgun.com/">Mailgun's</a> API.
<div class="form-group">
<div class="form-check" id="emailDisabled">
<input class="form-check-input" type="radio" name="email" id="emailDisabledRadio" value="emailDisabled">
@@ -152,9 +156,9 @@
</div>
</div>
<div id="emailSMTPArea">
<div class="form-group form-check">
<input type="checkbox" class="custom-control-input" id="emailSSL_TLS" checked>
<label for="emailSSL_TLS" class="custom-control-label" id="emailSSL_TLSLabel">Use SSL/TLS</label>
<div class="form-group form-check form-switch">
<input type="checkbox" class="form-check-input" id="emailSSL_TLS" checked>
<label for="emailSSL_TLS" class="form-check-label" id="emailSSL_TLSLabel">Use SSL/TLS</label>
<small class="form-text text-muted">Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587.</small>
</div>
<div class="form-group form-row">
@@ -236,7 +240,7 @@
<div class="card-body">
<h5 class="card-title">Password Resets</h5>
<p class="card-text">
When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jellyfin-accounts 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.
When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jfa-go will then read this file, and send the PIN to the user's email. Try it now, and put the folder that it informs you it put the file in below. Also, if enter a custom email subject if you don't like the default one.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="pwrEnabled" value="enabled">

View File

@@ -2,15 +2,17 @@ package main
import (
"bytes"
// "context"
"context"
"crypto/tls"
"fmt"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
"html/template"
"net/smtp"
"strings"
"time"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
)
type Emailer struct {
@@ -18,6 +20,8 @@ type Emailer struct {
sendType, sendMethod, fromAddr, fromName string
content Email
mg *mailgun.MailgunImpl
mime string
host string
}
type Email struct {
@@ -46,31 +50,35 @@ func (email *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern,
return
}
func (email *Emailer) init(ctx *appContext) {
email.fromAddr = ctx.config.Section("email").Key("address").String()
email.fromName = ctx.config.Section("email").Key("from").String()
email.sendMethod = ctx.config.Section("email").Key("method").String()
func (email *Emailer) init(app *appContext) {
email.fromAddr = app.config.Section("email").Key("address").String()
email.fromName = app.config.Section("email").Key("from").String()
email.sendMethod = app.config.Section("email").Key("method").String()
if email.sendMethod == "mailgun" {
email.mg = mailgun.NewMailgun(strings.Split(email.fromAddr, "@")[1], ctx.config.Section("mailgun").Key("api_key").String())
api_url := ctx.config.Section("mailgun").Key("api_url").String()
email.mg = mailgun.NewMailgun(strings.Split(email.fromAddr, "@")[1], app.config.Section("mailgun").Key("api_key").String())
api_url := app.config.Section("mailgun").Key("api_url").String()
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages'
if strings.Contains(api_url, "messages") {
api_url = api_url[0:strings.LastIndex(api_url, "/")]
api_url = api_url[0:strings.LastIndex(api_url, "/")]
}
email.mg.SetAPIBase(api_url)
} else if email.sendMethod == "smtp" {
app.host = app.config.Section("smtp").Key("server").String()
email.smtpAuth = smtp.PlainAuth("", email.fromAddr, app.config.Section("smtp").Key("password").String(), app.host)
}
}
func (email *Emailer) constructInvite(code string, invite Invite, ctx *appContext) error {
email.content.subject = ctx.config.Section("invite_emails").Key("subject").String()
func (email *Emailer) constructInvite(code string, invite Invite, app *appContext) error {
email.content.subject = app.config.Section("invite_emails").Key("subject").String()
expiry := invite.ValidTill
d, t, expires_in := email.formatExpiry(expiry, false, ctx.datePattern, ctx.timePattern)
message := ctx.config.Section("email").Key("message").String()
invite_link := ctx.config.Section("invite_emails").Key("url_base").String()
d, t, expires_in := email.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
invite_link := app.config.Section("invite_emails").Key("url_base").String()
invite_link = fmt.Sprintf("%s/%s", invite_link, code)
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("invite_emails").Key("email_" + key).String()
fpath := app.config.Section("invite_emails").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@@ -96,11 +104,11 @@ func (email *Emailer) constructInvite(code string, invite Invite, ctx *appContex
return nil
}
func (email *Emailer) constructExpiry(code string, invite Invite, ctx *appContext) error {
func (email *Emailer) constructExpiry(code string, invite Invite, app *appContext) error {
email.content.subject = "Notice: Invite expired"
expiry := ctx.formatDatetime(invite.ValidTill)
expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("notifications").Key("expiry_" + key).String()
fpath := app.config.Section("notifications").Key("expiry_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@@ -123,17 +131,17 @@ func (email *Emailer) constructExpiry(code string, invite Invite, ctx *appContex
return nil
}
func (email *Emailer) constructCreated(code, username, address string, invite Invite, ctx *appContext) error {
func (email *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) error {
email.content.subject = "Notice: User created"
created := ctx.formatDatetime(invite.Created)
created := app.formatDatetime(invite.Created)
var tplAddress string
if ctx.config.Section("email").Key("no_username").MustBool(false) {
if app.config.Section("email").Key("no_username").MustBool(false) {
tplAddress = "n/a"
} else {
tplAddress = address
}
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("notifications").Key("created_" + key).String()
fpath := app.config.Section("notifications").Key("created_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@@ -158,12 +166,12 @@ func (email *Emailer) constructCreated(code, username, address string, invite In
return nil
}
func (email *Emailer) constructReset(pwr Pwr, ctx *appContext) error {
email.content.subject = ctx.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin")
d, t, expires_in := email.formatExpiry(pwr.Expiry, true, ctx.datePattern, ctx.timePattern)
message := ctx.config.Section("email").Key("message").String()
func (email *Emailer) constructReset(pwr Pwr, app *appContext) error {
email.content.subject = app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin")
d, t, expires_in := email.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("password_resets").Key("email_" + key).String()
fpath := app.config.Section("password_resets").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@@ -190,7 +198,7 @@ func (email *Emailer) constructReset(pwr Pwr, ctx *appContext) error {
return nil
}
func (email *Emailer) send(address string, ctx *appContext) error {
func (email *Emailer) send(address string, app *appContext) error {
if email.sendMethod == "mailgun" {
message := email.mg.NewMessage(
fmt.Sprintf("%s <%s>", email.fromName, email.fromAddr),
@@ -198,12 +206,35 @@ func (email *Emailer) send(address string, ctx *appContext) error {
email.content.text,
address)
message.SetHtml(email.content.html)
mgctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
mgapp, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
_, _, err := email.mg.Send(mgctx, message)
_, _, err := email.mg.Send(mgapp, message)
if err != nil {
return err
}
} else if email.sendMethod == "smtp" {
e := jEmail.NewEmail()
e.Subject = email.content.subject
e.From = fmt.Sprintf("%s <%s>", email.fromName, email.fromAddr)
e.To = []string{address}
e.Text = []byte(email.content.text)
e.HTML = []byte(email.content.html)
smtpType := app.config.Section("smtp").Key("encryption").String()
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: app.host,
}
var err error
if smtpType == "ssl_tls" {
port := app.config.Section("smtp").Key("port").MustInt(465)
server := fmt.Sprintf("%s:%d", app.host, port)
err = e.SendWithTLS(server, email.smtpAuth, tlsConfig)
} else if smtpType == "starttls" {
port := app.config.Section("smtp").Key("port").MustInt(587)
server := fmt.Sprintf("%s:%d", app.host, port)
e.SendWithStartTLS(server, email.smtpAuth, tlsConfig)
}
return err
}
return nil
}

10
go.mod
View File

@@ -5,21 +5,23 @@ go 1.14
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/fsnotify/fsnotify v1.4.9
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-contrib/pprof v1.3.0
github.com/gin-contrib/static v0.0.0-20200815103939-31fb0c56a3d1
github.com/gin-gonic/gin v1.6.3
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-playground/validator/v10 v10.3.0 // indirect
github.com/golang/protobuf v1.4.2 // indirect
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e
github.com/json-iterator/go v1.1.10 // indirect
github.com/knz/strtime v0.0.0-20200318182718-be999391ffa9
github.com/lithammer/shortuuid/v3 v3.0.4
github.com/mailgun/mailgun-go/v4 v4.1.3
github.com/mailru/easyjson v0.7.2 // indirect
github.com/mailru/easyjson v0.7.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1 // indirect
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.57.0
gopkg.in/ini.v1 v1.60.0
gopkg.in/yaml.v2 v2.3.0 // indirect
)

13
go.sum
View File

@@ -15,11 +15,16 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqL
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/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.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-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=
@@ -59,6 +64,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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/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/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.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -83,6 +90,8 @@ 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/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=
@@ -128,6 +137,8 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtD
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -160,6 +171,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
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/yaml.v2 v2.2.2/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=

6
images/README.md Normal file
View File

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

BIN
images/admin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

BIN
images/jfa-go-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

22
images/jfa-go-icon.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 113 KiB

BIN
images/jfa-go-social.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

337
images/jfa-go-social.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

BIN
images/jfa.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -1,25 +0,0 @@
{
"Ucfde4vQQxFFVbN8zopiCA": {
"created": "28/7/20 14:40",
"no-limit": true,
"valid_till": "2020-07-29T14:40:40.100081",
"email": "hrfee@pm.me",
"used-by": [
[
"test@test.com",
"28/7/20 14:40"
]
],
"notify": {
"harveyltindall@gmail.com": {
"notify-expiry": true,
"notify-creation": true
}
}
},
"kr6M9PirRk0gmlCgaJTB-g": {
"created": "28/7/20 14:41",
"remaining-uses": 20,
"valid_till": "2020-07-29T14:41:18.130088"
}
}

View File

@@ -37,15 +37,25 @@ type Jellyfin struct {
userId string
httpClient *http.Client
loginParams map[string]string
userCache []map[string]interface{}
cacheExpiry time.Time
cacheLength int
noFail bool
}
func (jf *Jellyfin) timeoutHandler() {
if r := recover(); r != nil {
log.Fatalf("Failed to authenticate with Jellyfin @ %s: Timed out", jf.server)
out := fmt.Sprintf("Failed to authenticate with Jellyfin @ %s: Timed out", jf.server)
if jf.noFail {
log.Printf(out)
} else {
log.Fatalf(out)
}
}
}
func (jf *Jellyfin) init(server, client, version, device, deviceId string) error {
func newJellyfin(server, client, version, device, deviceId string) (*Jellyfin, error) {
jf := &Jellyfin{}
jf.server = server
jf.client = client
jf.version = version
@@ -73,7 +83,9 @@ func (jf *Jellyfin) init(server, client, version, device, deviceId string) error
data, _ := ioutil.ReadAll(resp.Body)
json.Unmarshal(data, &jf.serverInfo)
}
return nil
jf.cacheLength = 30
jf.cacheExpiry = time.Now()
return jf, nil
}
func (jf *Jellyfin) authenticate(username, password string) (map[string]interface{}, int, error) {
@@ -199,20 +211,24 @@ func (jf *Jellyfin) getUsers(public bool) ([]map[string]interface{}, int, error)
var data io.Reader
var status int
var err error
if public {
url := fmt.Sprintf("%s/emby/Users/Public", jf.server)
data, status, err = jf._getReader(url, nil)
if time.Now().After(jf.cacheExpiry) {
if public {
url := fmt.Sprintf("%s/emby/Users/Public", jf.server)
data, status, err = jf._getReader(url, nil)
} else {
url := fmt.Sprintf("%s/emby/Users", jf.server)
data, status, err = jf._getReader(url, jf.loginParams)
} else {
url := fmt.Sprintf("%s/emby/Users", jf.server)
data, status, err = jf._getReader(url, jf.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
json.NewDecoder(data).Decode(&result)
jf.userCache = result
jf.cacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
return result, status, nil
}
if err != nil || status != 200 {
return nil, status, err
}
json.NewDecoder(data).Decode(&result)
return result, status, nil
return jf.userCache, 200, nil
}
func (jf *Jellyfin) userByName(username string, public bool) (map[string]interface{}, int, error) {
@@ -229,6 +245,13 @@ func (jf *Jellyfin) userByName(username string, public bool) (map[string]interfa
}
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 {

View File

@@ -1,34 +1,60 @@
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument(
"-y", "--yes", help="use assumed node bin directory.", action="store_true"
)
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
node_bin = local_path.parent / 'node_modules' / '.bin'
for mjml in [f for f in local_path.iterdir() if f.is_file() and 'mjml' in f.suffix]:
print(f'Compiling {mjml.name}')
local_path = Path(__file__).resolve().parent
out = runcmd("npm bin")
try:
node_bin = Path(out[0].decode("utf-8").rstrip())
except:
node_bin = Path(out.decode("utf-8").rstrip())
args = parser.parse_args()
if not args.yes:
print(f'assuming npm bin directory "{node_bin}". Is this correct?')
if input("[yY/nN]: ").lower() == "n":
node_bin = local_path.parent / "node_modules" / ".bin"
print(f'this? "{node_bin}"')
if input("[yY/nN]: ").lower() == "n":
node_bin = input("input bin directory: ")
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'{str(node_bin / "mjml")} {str(mjml)} -o {str(fname)}')
if fname.is_file():
print('Done.')
print("Done.")
html = [f for f in local_path.iterdir() if f.is_file() and 'html' in f.suffix]
html = [f for f in local_path.iterdir() if f.is_file() and "html" in f.suffix]
output = local_path.parent / 'data'
output = local_path.parent / "data"
for f in html:
shutil.copy(str(f),
str(output / f.name))
print(f'Copied {f.name} to {str(output / f.name)}')
txtfile = f.with_suffix('.txt')
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)}')
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.')
print(
f"Warning: {txtfile.name} does not exist. Text versions of emails should be supplied."
)

310
main.go
View File

@@ -1,21 +1,26 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
"github.com/gin-contrib/pprof"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"time"
)
// Username is JWT!
@@ -26,6 +31,7 @@ type User struct {
}
type appContext struct {
// defaults *Config
config *ini.File
config_path string
configBase_path string
@@ -36,8 +42,9 @@ type appContext struct {
bsVersion int
jellyfinLogin bool
users []User
jf Jellyfin
authJf Jellyfin
invalidTokens []string
jf *Jellyfin
authJf *Jellyfin
datePattern string
timePattern string
storage Storage
@@ -47,6 +54,7 @@ type appContext struct {
host string
port int
version string
quit chan os.Signal
}
func GenerateSecret(length int) (string, error) {
@@ -75,7 +83,6 @@ func setGinLogger(router *gin.Engine, debugMode bool) {
}(),
)
}))
gin.SetMode(gin.DebugMode)
} else {
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("[GIN] %s(%s) => %d\n",
@@ -84,234 +91,281 @@ func setGinLogger(router *gin.Engine, debugMode bool) {
param.StatusCode,
)
}))
gin.SetMode(gin.ReleaseMode)
}
}
func main() {
ctx := new(appContext)
// 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()
ctx.data_path = filepath.Join(userConfigDir, "jfa-go")
ctx.config_path = filepath.Join(ctx.data_path, "config.ini")
ctx.local_path = "data"
app.data_path = filepath.Join(userConfigDir, "jfa-go")
app.config_path = filepath.Join(app.data_path, "config.ini")
executable, _ := os.Executable()
app.local_path = filepath.Join(filepath.Dir(executable), "data")
ctx.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
ctx.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
dataPath := flag.String("data", ctx.data_path, "alternate path to data directory.")
configPath := flag.String("config", ctx.config_path, "alternate path to config file.")
dataPath := flag.String("data", app.data_path, "alternate path to data directory.")
configPath := flag.String("config", app.config_path, "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.")
flag.Parse()
if ctx.config_path == *configPath && ctx.data_path != *dataPath {
ctx.config_path = filepath.Join(*dataPath, "config.ini")
// attempt to apply command line flags correctly
if app.config_path == *configPath && app.data_path != *dataPath {
app.data_path = *dataPath
app.config_path = filepath.Join(app.data_path, "config.ini")
} else if app.config_path != *configPath && app.data_path == *dataPath {
app.config_path = *configPath
} else {
ctx.config_path = *configPath
ctx.data_path = *dataPath
app.config_path = *configPath
app.data_path = *dataPath
}
// Env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.
// 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 != "" {
ctx.config_path = v
app.config_path = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
ctx.data_path = v
app.data_path = v
}
os.Setenv("JFA_CONFIGPATH", ctx.config_path)
os.Setenv("JFA_DATAPATH", ctx.data_path)
os.Setenv("JFA_CONFIGPATH", app.config_path)
os.Setenv("JFA_DATAPATH", app.data_path)
var firstRun bool
if _, err := os.Stat(ctx.data_path); os.IsNotExist(err) {
os.Mkdir(ctx.data_path, 0700)
if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
os.Mkdir(app.data_path, 0700)
}
if _, err := os.Stat(ctx.config_path); os.IsNotExist(err) {
if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
firstRun = true
dConfigPath := filepath.Join(ctx.local_path, "config-default.ini")
dConfigPath := filepath.Join(app.local_path, "config-default.ini")
var dConfig *os.File
dConfig, err = os.Open(dConfigPath)
if err != nil {
ctx.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
app.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
}
defer dConfig.Close()
var nConfig *os.File
nConfig, err := os.Create(ctx.config_path)
nConfig, err := os.Create(app.config_path)
if err != nil {
ctx.err.Fatalf("Couldn't open config file for writing: \"%s\"", dConfigPath)
app.err.Fatalf("Couldn't open config file for writing: \"%s\"", app.config_path)
}
defer nConfig.Close()
_, err = io.Copy(nConfig, dConfig)
if err != nil {
ctx.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, ctx.config_path)
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
}
ctx.info.Printf("Copied default configuration to \"%s\"", ctx.config_path)
app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
}
var debugMode bool
var address string
if ctx.loadConfig() != nil {
ctx.err.Fatalf("Failed to load config file \"%s\"", ctx.config_path)
if app.loadConfig() != nil {
app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
}
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
}
ctx.version = ctx.config.Section("jellyfin").Key("version").String()
debugMode = ctx.config.Section("ui").Key("debug").MustBool(true)
if debugMode {
ctx.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
app.info.Println("WARNING: Don't use debug mode in production, as it exposes pprof on the network.")
app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
} else {
ctx.debug = log.New(ioutil.Discard, "", 0)
app.debug = log.New(ioutil.Discard, "", 0)
}
if !firstRun {
ctx.host = ctx.config.Section("ui").Key("host").String()
ctx.port = ctx.config.Section("ui").Key("port").MustInt(8056)
app.host = app.config.Section("ui").Key("host").String()
app.port = app.config.Section("ui").Key("port").MustInt(8056)
if *host != ctx.host && *host != "" {
ctx.host = *host
if *host != app.host && *host != "" {
app.host = *host
}
if *port != ctx.port && *port > 0 {
ctx.port = *port
if *port != app.port && *port > 0 {
app.port = *port
}
if h := os.Getenv("JFA_HOST"); h != "" {
ctx.host = h
app.host = h
if p := os.Getenv("JFA_PORT"); p != "" {
var port int
_, err := fmt.Sscan(p, &port)
if err == nil {
ctx.port = port
app.port = port
}
}
}
address = fmt.Sprintf("%s:%d", ctx.host, ctx.port)
address = fmt.Sprintf("%s:%d", app.host, app.port)
ctx.debug.Printf("Loaded config file \"%s\"", ctx.config_path)
app.debug.Printf("Loaded config file \"%s\"", app.config_path)
if ctx.config.Section("ui").Key("bs5").MustBool(false) {
ctx.cssFile = "bs5-jf.css"
ctx.bsVersion = 5
if app.config.Section("ui").Key("bs5").MustBool(false) {
app.cssFile = "bs5-jf.css"
app.bsVersion = 5
} else {
ctx.cssFile = "bs4-jf.css"
ctx.bsVersion = 4
app.cssFile = "bs4-jf.css"
app.bsVersion = 4
}
ctx.debug.Println("Loading storage")
app.debug.Println("Loading storage")
ctx.storage.invite_path = filepath.Join(ctx.data_path, "invites.json")
ctx.storage.loadInvites()
ctx.storage.emails_path = filepath.Join(ctx.data_path, "emails.json")
ctx.storage.loadEmails()
ctx.storage.policy_path = filepath.Join(ctx.data_path, "user_template.json")
ctx.storage.loadPolicy()
ctx.storage.configuration_path = filepath.Join(ctx.data_path, "user_configuration.json")
ctx.storage.loadConfiguration()
ctx.storage.displayprefs_path = filepath.Join(ctx.data_path, "user_displayprefs.json")
ctx.storage.loadDisplayprefs()
app.storage.invite_path = filepath.Join(app.data_path, "invites.json")
app.storage.loadInvites()
app.storage.emails_path = filepath.Join(app.data_path, "emails.json")
app.storage.loadEmails()
app.storage.policy_path = filepath.Join(app.data_path, "user_template.json")
app.storage.loadPolicy()
app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json")
app.storage.loadConfiguration()
app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json")
app.storage.loadDisplayprefs()
ctx.configBase_path = filepath.Join(ctx.local_path, "config-base.json")
config_base, _ := ioutil.ReadFile(ctx.configBase_path)
json.Unmarshal(config_base, &ctx.configBase)
app.configBase_path = filepath.Join(app.local_path, "config-base.json")
config_base, _ := ioutil.ReadFile(app.configBase_path)
json.Unmarshal(config_base, &app.configBase)
themes := map[string]string{
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", ctx.bsVersion),
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", ctx.bsVersion),
"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[ctx.config.Section("ui").Key("theme").String()]; ok {
ctx.cssFile = val
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
app.cssFile = val
}
ctx.debug.Printf("Using css file \"%s\"", ctx.cssFile)
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := GenerateSecret(16)
if err != nil {
ctx.err.Fatal(err)
app.err.Fatal(err)
}
os.Setenv("JFA_SECRET", secret)
ctx.jellyfinLogin = true
if val, _ := ctx.config.Section("ui").Key("jellyfin_login").Bool(); !val {
ctx.jellyfinLogin = false
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 = ctx.config.Section("ui").Key("username").String()
user.Password = ctx.config.Section("ui").Key("password").String()
ctx.users = append(ctx.users, user)
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 {
ctx.debug.Println("Using Jellyfin for authentication")
app.debug.Println("Using Jellyfin for authentication")
}
server := ctx.config.Section("jellyfin").Key("server").String()
ctx.jf.init(server, "jfa-go", ctx.version, "hrfee-arch", "hrfee-arch")
server := app.config.Section("jellyfin").Key("server").String()
app.jf, _ = newJellyfin(server, "jfa-go", app.version, "hrfee-arch", "hrfee-arch")
var status int
_, status, err = ctx.jf.authenticate(ctx.config.Section("jellyfin").Key("username").String(), ctx.config.Section("jellyfin").Key("password").String())
_, status, err = app.jf.authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
if status != 200 || err != nil {
ctx.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
}
ctx.info.Printf("Authenticated with %s", server)
ctx.authJf.init(server, "jfa-go", ctx.version, "auth", "auth")
app.info.Printf("Authenticated with %s", server)
app.authJf, _ = newJellyfin(server, "jfa-go", app.version, "auth", "auth")
ctx.loadStrftime()
app.loadStrftime()
validatorConf := ValidatorConf{
"characters": ctx.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": ctx.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": ctx.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": ctx.config.Section("password_validation").Key("number").MustInt(0),
"special characters": ctx.config.Section("password_validation").Key("special").MustInt(0),
"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 !ctx.config.Section("password_validation").Key("enabled").MustBool(false) {
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
for key := range validatorConf {
validatorConf[key] = 0
}
}
ctx.validator.init(validatorConf)
app.validator.init(validatorConf)
ctx.email.init(ctx)
app.email.init(app)
inviteDaemon := NewRepeater(time.Duration(60*time.Second), ctx)
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.Run()
if ctx.config.Section("password_resets").Key("enabled").MustBool(false) {
go ctx.StartPWR()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
go app.StartPWR()
}
} else {
debugMode = false
gin.SetMode(gin.ReleaseMode)
address = "0.0.0.0:8056"
}
ctx.info.Println("Loading routes")
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("data/static", false)))
router.LoadHTMLGlob("data/templates/*")
router.NoRoute(ctx.NoRouteHandler)
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.LoadHTMLGlob(filepath.Join(app.local_path, "templates", "*"))
router.NoRoute(app.NoRouteHandler)
if debugMode {
app.debug.Println("Loading pprof")
pprof.Register(router)
}
if !firstRun {
router.GET("/", ctx.AdminPage)
router.GET("/getToken", ctx.GetToken)
router.POST("/newUser", ctx.NewUser)
router.GET("/invite/:invCode", ctx.InviteProxy)
router.Use(static.Serve("/invite/", static.LocalFile("data/static", false)))
api := router.Group("/", ctx.webAuth())
api.POST("/generateInvite", ctx.GenerateInvite)
api.GET("/getInvites", ctx.GetInvites)
api.POST("/setNotify", ctx.SetNotify)
api.POST("/deleteInvite", ctx.DeleteInvite)
api.GET("/getUsers", ctx.GetUsers)
api.POST("/modifyUsers", ctx.ModifyEmails)
api.POST("/setDefaults", ctx.SetDefaults)
api.GET("/getConfig", ctx.GetConfig)
api.POST("/modifyConfig", ctx.ModifyConfig)
ctx.info.Printf("Starting router @ %s", address)
router.GET("/", app.AdminPage)
router.GET("/getToken", app.getToken)
router.POST("/newUser", app.NewUser)
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.GET("/invite/:invCode", app.InviteProxy)
api := router.Group("/", app.webAuth())
router.POST("/logout", app.Logout)
api.POST("/generateInvite", app.GenerateInvite)
api.GET("/getInvites", app.GetInvites)
api.POST("/setNotify", app.SetNotify)
api.POST("/deleteInvite", app.DeleteInvite)
api.GET("/getUsers", app.GetUsers)
api.POST("/modifyUsers", app.ModifyEmails)
api.POST("/setDefaults", app.SetDefaults)
api.GET("/getConfig", app.GetConfig)
api.POST("/modifyConfig", app.ModifyConfig)
app.info.Printf("Starting router @ %s", address)
} else {
router.GET("/", func(gc *gin.Context) {
gc.HTML(200, "setup.html", gin.H{})
})
router.POST("/testJF", ctx.TestJF)
router.POST("/modifyConfig", ctx.ModifyConfig)
ctx.info.Printf("Loading setup @ %s", address)
router.POST("/testJF", app.TestJF)
router.POST("/modifyConfig", 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)
<-app.quit
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)
}
router.Run(address)
}

View File

@@ -2,33 +2,34 @@ package main
import (
"encoding/json"
"github.com/fsnotify/fsnotify"
"io/ioutil"
"os"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
func (ctx *appContext) StartPWR() {
ctx.info.Println("Starting password reset daemon")
path := ctx.config.Section("password_resets").Key("watch_directory").String()
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) {
ctx.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path)
app.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path)
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
ctx.err.Printf("Couldn't initialise password reset daemon")
app.err.Printf("Couldn't initialise password reset daemon")
return
}
defer watcher.Close()
done := make(chan bool)
go pwrMonitor(ctx, watcher)
go pwrMonitor(app, watcher)
err = watcher.Add(path)
if err != nil {
ctx.err.Printf("Failed to start password reset daemon: %s", err)
app.err.Printf("Failed to start password reset daemon: %s", err)
}
<-done
}
@@ -39,7 +40,7 @@ type Pwr struct {
Expiry time.Time `json:"ExpirationDate"`
}
func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
for {
select {
case event, ok := <-watcher.Events:
@@ -56,29 +57,29 @@ func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
if len(pwr.Pin) == 0 || err != nil {
return
}
ctx.info.Printf("New password reset for user \"%s\"", pwr.Username)
app.info.Printf("New password reset for user \"%s\"", pwr.Username)
if ct := time.Now(); pwr.Expiry.After(ct) {
user, status, err := ctx.jf.userByName(pwr.Username, false)
user, status, err := app.jf.userByName(pwr.Username, false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
return
}
ctx.storage.loadEmails()
address, ok := ctx.storage.emails[user["Id"].(string)].(string)
app.storage.loadEmails()
address, ok := app.storage.emails[user["Id"].(string)].(string)
if !ok {
ctx.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
return
}
if ctx.email.constructReset(pwr, ctx) != nil {
ctx.err.Printf("Failed to construct password reset email for %s", pwr.Username)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("Failed to send password reset email to \"%s\"", address)
if app.email.constructReset(pwr, app) != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username)
} else if app.email.send(address, app) != nil {
app.err.Printf("Failed to send password reset email to \"%s\"", address)
} else {
ctx.info.Printf("Sent password reset email to \"%s\"", address)
app.info.Printf("Sent password reset email to \"%s\"", address)
}
} else {
ctx.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)
app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)
}
}
@@ -86,7 +87,7 @@ func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
if !ok {
return
}
ctx.err.Printf("Password reset daemon: %s", err)
app.err.Printf("Password reset daemon: %s", err)
}
}
}

View File

@@ -2,34 +2,81 @@
import sass
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument(
"-y", "--yes", help="use assumed node bin directory.", action="store_true"
)
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
node_bin = local_path.parent / 'node_modules' / '.bin'
out = runcmd("npm bin")
for bsv in [d for d in local_path.iterdir() if 'bs' in d.name]:
scss = bsv / f'{bsv.name}-jf.scss'
css = bsv / f'{bsv.name}-jf.css'
min_css = bsv.parents[1] / 'data' / 'static' / f'{bsv.name}-jf.css'
with open(css, 'w') as f:
f.write(sass.compile(filename=str(scss.resolve()),
output_style='expanded',
precision=6))
try:
node_bin = Path(out[0].decode("utf-8").rstrip())
except:
node_bin = Path(out.decode("utf-8").rstrip())
args = parser.parse_args()
if not args.yes:
print(f'assuming npm bin directory "{node_bin}". Is this correct?')
if input("[yY/nN]: ").lower() == "n":
node_bin = local_path.parent / "node_modules" / ".bin"
print(f'this? "{node_bin}"')
if input("[yY/nN]: ").lower() == "n":
node_bin = input("input bin directory: ")
for bsv in [d for d in local_path.iterdir() if "bs" in d.name]:
scss = bsv / f"{bsv.name}-jf.scss"
css = bsv / f"{bsv.name}-jf.css"
min_css = bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css"
with open(css, "w") as f:
f.write(
sass.compile(
filename=str(scss.resolve()), output_style="expanded", precision=6
)
)
if css.exists():
print(f'{bsv.name}: Compiled.')
runcmd(f'{str((node_bin / "postcss").resolve())} {str(css.resolve())} --replace --use autoprefixer')
print(f'{bsv.name}: Prefixed.')
runcmd(f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}')
print(f"{bsv.name}: Compiled.")
# postcss only excepts forwards slashes? weird.
cssPath = str(css.resolve())
if os.name == "nt":
cssPath = cssPath.replace("\\", "/")
runcmd(
f'{str((node_bin / "postcss").resolve())} {cssPath} --replace --use autoprefixer'
)
print(f"{bsv.name}: Prefixed.")
runcmd(
f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}'
)
if min_css.exists():
print(f'{bsv.name}: Minified and copied to {str(min_css.resolve())}.')
for v in [('bootstrap', 'bs5'), ('bootstrap4', 'bs4')]:
new_path = str((local_path.parent / 'data' / 'static' / (v[1] + '.css')).resolve())
shutil.copy(str((local_path.parent / 'node_modules' / v[0] / 'dist' / 'css' / 'bootstrap.min.css').resolve()),
new_path)
print(f'Copied {v[1]} to {new_path}')
print(f"{bsv.name}: Minified and copied to {str(min_css.resolve())}.")
for v in [("bootstrap", "bs5"), ("bootstrap4", "bs4")]:
new_path = str((local_path.parent / "data" / "static" / (v[1] + ".css")).resolve())
shutil.copy(
str(
(
local_path.parent
/ "node_modules"
/ v[0]
/ "dist"
/ "css"
/ "bootstrap.min.css"
).resolve()
),
new_path,
)
print(f"Copied {v[1]} to {new_path}")

View File

@@ -1,17 +1,26 @@
#!/usr/bin/env python3
import subprocess
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()
print('Installing npm packages')
root_path = Path(__file__).parents[1]
runcmd(f'npm install --prefix {root_path}')
if os.name == 'nt':
root_path /= 'node_modules'
if (root_path / 'node_modules' / 'cleancss').exists():
runcmd('npm install')
if (root_path / 'node_modules' / 'cleancss').exists() or (root_path / 'cleancss').exists():
print(f'Installed successfully in {str((root_path / "node_modules").resolve())}.')

View File

@@ -10,14 +10,14 @@ type testReq struct {
Password string `json:"jfPassword"`
}
func (ctx *appContext) TestJF(gc *gin.Context) {
func (app *appContext) TestJF(gc *gin.Context) {
var req testReq
gc.BindJSON(&req)
tempjf := Jellyfin{}
tempjf.init(req.Host, "jfa-go-setup", ctx.version, "auth", "auth")
tempjf, _ := newJellyfin(req.Host, "jfa-go-setup", app.version, "auth", "auth")
tempjf.noFail = true
_, status, err := tempjf.authenticate(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
ctx.info.Printf("Auth failed with code %d (%s)", status, err)
app.info.Printf("Auth failed with code %d (%s)", status, err)
gc.JSON(401, map[string]bool{"success": false})
return
}

View File

@@ -1,54 +1,55 @@
package main
import (
"github.com/gin-gonic/gin"
"net/http"
"github.com/gin-gonic/gin"
)
func (ctx *appContext) AdminPage(gc *gin.Context) {
bs5 := ctx.config.Section("ui").Key("bs5").MustBool(false)
emailEnabled, _ := ctx.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := ctx.config.Section("notifications").Key("enabled").Bool()
func (app *appContext) AdminPage(gc *gin.Context) {
bs5 := app.config.Section("ui").Key("bs5").MustBool(false)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
gc.HTML(http.StatusOK, "admin.html", gin.H{
"bs5": bs5,
"cssFile": ctx.cssFile,
"cssFile": app.cssFile,
"contactMessage": "",
"email_enabled": emailEnabled,
"notifications": notificationsEnabled,
})
}
func (ctx *appContext) InviteProxy(gc *gin.Context) {
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 ctx.checkInvite(code, false, "") {
if _, ok := ctx.storage.invites[code]; ok {
email := ctx.storage.invites[code].Email
// if app.checkInvite(code, false, "") {
if _, ok := app.storage.invites[code]; ok {
email := app.storage.invites[code].Email
gc.HTML(http.StatusOK, "form.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contac_message").String(),
"helpMessage": ctx.config.Section("ui").Key("help_message").String(),
"successMessage": ctx.config.Section("ui").Key("success_message").String(),
"jfLink": ctx.config.Section("jellyfin").Key("public_server").String(),
"validate": ctx.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": ctx.validator.getCriteria(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contac_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,
"username": !ctx.config.Section("email").Key("no_username").MustBool(false),
"username": !app.config.Section("email").Key("no_username").MustBool(false),
})
} else {
gc.HTML(404, "invalidCode.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contac_message").String(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contac_message").String(),
})
}
}
func (ctx *appContext) NoRouteHandler(gc *gin.Context) {
func (app *appContext) NoRouteHandler(gc *gin.Context) {
gc.HTML(404, "404.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contact_message").String(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}