Compare commits

...

20 Commits

Author SHA1 Message Date
Harvey Tindall
a9b11012bc Matrix: Add example images 2021-05-31 20:56:29 +01:00
Harvey Tindall
e7cb1f516b Mention wiki in Telegram/Discord/Matrix settings descriptions 2021-05-31 20:32:16 +01:00
Harvey Tindall
375022ba95 Matrix: Add token generation wizard
Pressing the "+" next to matrix in settings allows you to enter a
homeserver, username and password to enable matrix and generate an
access token.
2021-05-30 23:09:20 +01:00
Harvey Tindall
75fdf6ec3d Matrix: Connect on accounts tab, customizable chat topic 2021-05-30 11:47:41 +01:00
Harvey Tindall
59ebf52fe2 Matrix: Show matrix on accounts page 2021-05-30 00:05:46 +01:00
Harvey Tindall
89fb3fa619 Matrix: Notifications 2021-05-29 21:05:12 +01:00
Harvey Tindall
9bd6abadf4 Matrix: Fix user storage 2021-05-29 19:50:33 +01:00
Harvey Tindall
4e826f4167 Matrix: Store user on sign-up 2021-05-29 18:51:43 +01:00
Harvey Tindall
e97b90d4d7 Matrix: Setup bot, add PIN verification
PIN is verified but not used currently. Works a little different than
the others, you input your matrix user ID and then the PIN is sent to
you. The bot doesn't support E2EE, so the bot being the first one to
message ensures the chat is unencrypted.
2021-05-29 17:43:11 +01:00
Harvey Tindall
fb6256d1ed Telegram: Escape all necessary characters
Fixes #108.
2021-05-25 23:03:13 +01:00
Harvey Tindall
7035a3fe9c Tray: Add button to open logs 2021-05-25 20:16:42 +01:00
Harvey Tindall
62c29d55cc Log output to TEMP/jfa-go.log when Tray enabled
-H=windowsgui completely disables Stdout/Stderr on Windows, so logging
is enabled.
2021-05-25 17:59:41 +01:00
Harvey Tindall
a83dbcf3ab debian/ubuntu, not just debian 2021-05-25 01:33:58 +01:00
Harvey Tindall
e48bdcc45b README: change install section layout
Downloads at the top also now link to parts in the install section.
2021-05-24 22:58:11 +01:00
Harvey Tindall
0b473ef01f don't put .debs on buildrone; link to instructions at top of README 2021-05-24 20:30:31 +01:00
Harvey Tindall
e03525a1d1 separate codenames for stable & unstable
templates don't work in name_template as i though, so this should work
instead.
2021-05-24 19:53:53 +01:00
Harvey Tindall
087172c79e fix package naming to avoid conflicts 2021-05-24 18:46:54 +01:00
Harvey Tindall
8fd919bf04 remove chglog, add steps to upload to apt.hrfee.dev
chglog isn't actually needed. Packages are uploaded as jfa-go(-git) and
jfa-go-tray(-git).
2021-05-24 18:37:26 +01:00
Harvey Tindall
2ad84db482 add inaccurate chglog
not really correct, tagged as v0.3.6 despite the few extra commits.
2021-05-24 16:33:20 +01:00
Harvey Tindall
85536ff79e expand CONTRIBUTING, print if tray enabled on startup 2021-05-24 15:58:43 +01:00
36 changed files with 1167 additions and 193 deletions

View File

@@ -10,6 +10,9 @@ steps:
- git fetch --tags
- name: release
image: golang:latest
volumes:
- name: ssh_key
path: /id_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
@@ -26,7 +29,13 @@ steps:
- ./scripts/version.sh ../goreleaser
- wget https://builds.hrfee.pw/upload.py -P ../
- pip3 install requests
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
- bash -c 'python3 ../upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
trigger:
event:
- tag
@@ -73,6 +82,9 @@ type: docker
steps:
- name: build
image: golang:latest
volumes:
- name: ssh_key
path: /id_rsa
commands:
- apt-get update -y
- apt-get install build-essential python3-pip curl software-properties-common sed upx gcc libgtk-3-dev libappindicator3-dev gcc-mingw-w64-x86-64 -y
@@ -83,11 +95,17 @@ steps:
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
- wget https://builds.hrfee.pw/upload.py
- pip3 install requests
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.deb ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
trigger:
branch:
- main

View File

@@ -110,7 +110,8 @@ changelog:
- '^test:'
nfpms:
- id: notray
file_name_template: '{{ .ProjectName }}_{{ .Arch }}_v{{ .Version }}'
file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}'
package_name: jfa-go
homepage: https://github.com/hrfee/jfa-go
description: A web app for managing users on Jellyfin
maintainer: Harvey Tindall <hrfee@hrfee.dev>
@@ -127,7 +128,8 @@ nfpms:
- deb
- rpm
- id: tray
file_name_template: '{{ .ProjectName }}_TrayIcon_{{ .Arch }}_v{{ .Version }}'
file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_TrayIcon_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}'
package_name: jfa-go-tray
homepage: https://github.com/hrfee/jfa-go
description: A web app for managing users on Jellyfin
maintainer: Harvey Tindall <hrfee@hrfee.dev>
@@ -145,6 +147,10 @@ nfpms:
- rpm
overrides:
deb:
conflicts:
- jfa-go
replaces:
- jfa-go
dependencies:
- libappindicator3-1
rpm:
@@ -153,4 +159,3 @@ nfpms:
apk:
dependencies:
- libappindicator

View File

@@ -1,6 +1,11 @@
#### Code
I use 4 spaces for indentation. Go should ideally be formatted with `goimports` and/or `gofmt`. I don't use a formatter on typescript, so don't worry about that.
Code in Go should ideally use `PascalCase` for exported values, and `camelCase` for non-exported, JSON for transferring data should use `snake_case`, and Typescript should use `camelCase`. Forgive me for my many inconsistencies in this, and feel free to fix them if you want.
Functions in Go that need to access `*appContext` should be generally be receivers, except when the behaviour could be seen as somewhat independent from it (`email.go` is the best example, its behaviour is broadly independent from the main app except from a couple config values).
#### Compiling
Prefix each of these with `make DEBUG=on INTERNAL=off `:

View File

@@ -4,12 +4,15 @@
[![Translation status](https://weblate.hrfee.pw/widgets/jfa-go/-/svg-badge.svg)](https://weblate.hrfee.pw/engage/jfa-go/)
##### Downloads:
##### [dockerhub](https://hub.docker.com/r/hrfee/jfa-go) | [stable](https://github.com/hrfee/jfa-go/releases) | [nightly](https://builds.hrfee.pw/view/hrfee/jfa-go) | [aur stable](https://aur.archlinux.org/packages/jfa-go) | [aur binary](https://aur.archlinux.org/packages/jfa-go-bin) | [aur nightly](https://aur.archlinux.org/packages/jfa-go-git)
##### [docker](#docker) | [debian/ubuntu](#debian) | [arch (aur)](#aur) | [other platforms](#other-platforms)
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
😂).
#### 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.
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
@@ -43,7 +46,9 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
#### Install
The [Docker](https://hub.docker.com/r/hrfee/jfa-go) image is your best bet.
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
##### [Docker](https://hub.docker.com/r/hrfee/jfa-go)
```sh
docker create \
--name "jfa-go" \ # Whatever you want to name it
@@ -54,11 +59,41 @@ docker create \
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
```
`TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
Available on the AUR as [jfa-go](https://aur.archlinux.org/packages/jfa-go/), [jfa-go-bin](https://aur.archlinux.org/packages/jfa-go) or [jfa-go-git](https://aur.archlinux.org/packages/jfa-go-git/).
##### [Debian/Ubuntu](https://apt.hrfee.dev)
```sh
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
For other platforms, grab an archive from the release section for your platform (or nightly builds [here](https://builds.hrfee.dev/view/hrfee/jfa-go)), and extract the `jfa-go` executable to somewhere useful.
# For stable releases
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
# ------
# For unstable releases
echo "deb https://apt.hrfee.dev trusty-unstable main" | sudo tee /etc/apt/sources.list.d/hrfee.list
# ------
sudo apt-get update
# For servers
sudo apt-get install jfa-go
# ------
# For desktops/servers with GUI (has dependencies)
sudo apt-get install jfa-go-tray
# ------
```
##### Arch
Available on the AUR as:
* [jfa-go](https://aur.archlinux.org/packages/jfa-go/) (stable)
* [jfa-go-bin](https://aur.archlinux.org/packages/jfa-go) (pre-compiled, stable)
* [jfa-go-git](https://aur.archlinux.org/packages/jfa-go-git/) (nightly)
##### Other platforms
Download precompiled binaries from:
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
Run the executable to start.
@@ -73,17 +108,28 @@ Otherwise, full build instructions can be found [here](https://github.com/hrfee/
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.
```
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")
Usage of jfa-go:
start
start jfa-go as a daemon and run in the background.
stop
stop a daemonized instance of jfa-go.
systemd
generate a systemd .service file.
-config, -c string
alternate path to config file. (default "/home/hrfee/.config/jfa-go/config.ini")
-data, -d string
alternate path to data directory. (default "/home/hrfee/.config/jfa-go")
-debug
Enables debug logging and exposes pprof.
Enables debug logging.
-help, -h
prints this message.
-host string
alternate address to host web ui on.
-port int
-port, -p int
alternate port to host web ui on.
-pprof
Exposes pprof profiler on /debug/pprof.
-swagger
Enable swagger at /swagger/index.html
```

245
api.go
View File

@@ -354,6 +354,34 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
}
}
}
var matrixUser MatrixUser
matrixVerified := false
if matrixEnabled {
if req.MatrixPIN == "" {
if app.config.Section("matrix").Key("required").MustBool(false) {
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Matrix verification not completed", req.Code)
respond(401, "errorMatrixVerification", gc)
}
success = false
return
}
} else {
user, ok := app.matrix.tokens[req.MatrixPIN]
if !ok || !user.Verified {
matrixVerified = false
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Matrix PIN was invalid", req.Code)
respond(401, "errorInvalidPIN", gc)
}
success = false
return
}
matrixVerified = user.Verified
matrixUser = *user.User
}
}
telegramTokenIndex := -1
if telegramEnabled {
if req.TelegramPIN == "" {
@@ -536,7 +564,17 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
}
}
if matrixVerified {
matrixUser.Contact = req.MatrixContact
delete(app.matrix.tokens, req.MatrixPIN)
if app.storage.matrix == nil {
app.storage.matrix = map[string]MatrixUser{}
}
app.storage.matrix[user.ID] = matrixUser
if err := app.storage.storeMatrixUsers(); err != nil {
app.err.Printf("Failed to store Matrix users: %v", err)
}
}
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified {
name := app.getAddressOrName(user.ID)
app.debug.Printf("%s: Sending welcome message to %s", req.Username, name)
@@ -1239,10 +1277,14 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
}
if dc, ok := app.storage.discord[jfUser.ID]; ok {
user.Discord = dc.Username + "#" + dc.Discriminator
user.DiscordID = dc.ID
user.NotifyThroughDiscord = dc.Contact
if mxUser, ok := app.storage.matrix[jfUser.ID]; ok {
user.Matrix = mxUser.UserID
user.NotifyThroughMatrix = mxUser.Contact
}
if dcUser, ok := app.storage.discord[jfUser.ID]; ok {
user.Discord = dcUser.Username + "#" + dcUser.Discriminator
user.DiscordID = dcUser.ID
user.NotifyThroughDiscord = dcUser.Contact
}
resp.UserList[i] = user
i++
@@ -1498,6 +1540,8 @@ func (app *appContext) GetConfig(gc *gin.Context) {
resp.Sections["email"].Settings["language"] = el
resp.Sections["password_resets"].Settings["language"] = pl
resp.Sections["telegram"].Settings["language"] = tl
resp.Sections["discord"].Settings["language"] = tl
resp.Sections["matrix"].Settings["language"] = tl
gc.JSON(200, resp)
}
@@ -1506,6 +1550,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
// @Produce json
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings."
// @Success 200 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /config [post]
// @Security Bearer
// @tags Configuration
@@ -1531,7 +1576,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
}
}
}
tempConfig.SaveTo(app.configPath)
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
respondBool(500, false, gc)
return
}
app.debug.Println("Config saved")
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) {
@@ -2089,6 +2138,7 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
return
}
if tgUser, ok := app.storage.telegram[req.ID]; ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
app.storage.telegram[req.ID] = tgUser
if err := app.storage.storeTelegramUsers(); err != nil {
@@ -2096,13 +2146,16 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.err.Printf("Telegram: Failed to store users: %v", err)
return
}
msg := ""
if !req.Telegram {
msg = " not"
if change {
msg := ""
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
}
if dcUser, ok := app.storage.discord[req.ID]; ok {
change := dcUser.Contact != req.Discord
dcUser.Contact = req.Discord
app.storage.discord[req.ID] = dcUser
if err := app.storage.storeDiscordUsers(); err != nil {
@@ -2110,13 +2163,33 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.err.Printf("Discord: Failed to store users: %v", err)
return
}
msg := ""
if !req.Discord {
msg = " not"
if change {
msg := ""
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
}
}
if mxUser, ok := app.storage.matrix[req.ID]; ok {
change := mxUser.Contact != req.Matrix
mxUser.Contact = req.Matrix
app.storage.matrix[req.ID] = mxUser
if err := app.storage.storeMatrixUsers(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Matrix: Failed to store users: %v", err)
return
}
if change {
msg := ""
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
}
if email, ok := app.storage.emails[req.ID]; ok {
change := email.Contact != req.Email
email.Contact = req.Email
app.storage.emails[req.ID] = email
if err := app.storage.storeEmails(); err != nil {
@@ -2124,11 +2197,13 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.err.Printf("Failed to store emails: %v", err)
return
}
msg := ""
if !req.Email {
msg = " not"
if change {
msg := ""
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
}
respondBool(200, true, gc)
}
@@ -2233,6 +2308,140 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Generate and send a new PIN to a specified Matrix user.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
// @Router /invite/{invCode}/matrix/user [post]
// @tags Other
func (app *appContext) MatrixSendPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok {
respondBool(401, false, gc)
return
}
var req MatrixSendPINDTO
gc.BindJSON(&req)
if req.UserID == "" {
respondBool(400, false, gc)
return
}
ok := app.matrix.SendStart(req.UserID)
if !ok {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Param userID path string true "Matrix User ID"
// @Router /invite/{invCode}/matrix/verified/{userID}/{pin} [get]
// @tags Other
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok {
app.debug.Println("Matrix: Invite code was invalid")
respondBool(401, false, gc)
return
}
userID := gc.Param("userID")
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
respondBool(200, false, gc)
return
}
user.Verified = true
app.matrix.tokens[pin] = user
respondBool(200, true, gc)
}
// @Summary Generates a Matrix access token from a username and password.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post]
// @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO
gc.BindJSON(&req)
if req.Username == "" || req.Password == "" {
respond(400, "errorLoginBlank", gc)
return
}
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil {
app.err.Printf("Matrix: Failed to generate token: %v", err)
respond(401, "Unauthorized", gc)
return
}
tempConfig, _ := ini.Load(app.configPath)
matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver)
matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Links a Matrix user to a Jellyfin account via user IDs. Notifications are turned on by default.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post]
// @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO
gc.BindJSON(&req)
if app.storage.matrix == nil {
app.storage.matrix = map[string]MatrixUser{}
}
roomID, err := app.matrix.CreateRoom(req.UserID)
if err != nil {
app.err.Printf("Matrix: Failed to create room: %v", err)
respondBool(500, false, gc)
return
}
app.storage.matrix[req.JellyfinID] = MatrixUser{
UserID: req.UserID,
RoomID: roomID,
Lang: "en-us",
Contact: true,
}
if err := app.storage.storeMatrixUsers(); err != nil {
app.err.Printf("Failed to store Matrix users: %v", err)
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional).
// @Produce json
// @Success 200 {object} DiscordUsersDTO

View File

@@ -15,6 +15,7 @@ var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("")
@@ -43,7 +44,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
@@ -83,22 +84,26 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
matrixEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
}
if !emailEnabled && !telegramEnabled && !discordEnabled {
if !emailEnabled && !telegramEnabled && !discordEnabled && !matrixEnabled {
messagesEnabled = false
}

View File

@@ -568,7 +568,7 @@
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Require Discord connection on sign-up."
"description": "Require Discord connection on sign-up. See the jfa-go wiki for info on setting this up."
},
"token": {
"name": "API Token",
@@ -633,7 +633,7 @@
"order": [],
"meta": {
"name": "Telegram",
"description": "Settings for Telegram signup/notifications"
"description": "Settings for Telegram signup/notifications. See the jfa-go wiki for info on setting this up."
},
"settings": {
"enabled": {
@@ -676,6 +676,80 @@
}
}
},
"matrix": {
"order": [],
"meta": {
"name": "Matrix",
"description": "Settings for Matrix invites/signup/notifications. See the jfa-go wiki for info on setting this up."
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable signup verification through Matrix and the sending of notifications through it.\nSee the jfa-go wiki for setting up a bot."
},
"required": {
"name": "Require on sign-up",
"required": false,
"required_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Require Matrix connection on sign-up."
},
"homeserver": {
"name": "Home Server URL",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Matrix Home server URL."
},
"token": {
"name": "Access Token",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Matrix Bot API Token."
},
"user_id": {
"name": "Bot User ID",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "User ID of bot account (Example: @jfa-bot:riot.im)"
},
"topic": {
"name": "Chat topic",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "Jellyfin notifications",
"description": "Topic of Matrix private chats."
},
"language": {
"name": "Language",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default Matrix message language. Visit weblate if you'd like to translate."
}
}
},
"password_resets": {
"order": [],
"meta": {
@@ -1225,6 +1299,14 @@
"value": "",
"description": "Stores telegram user IDs and language preferences."
},
"matrix_users": {
"name": "Matrix users",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores matrix user IDs and language preferences."
},
"discord_users": {
"name": "Discord users",
"required": false,

View File

@@ -25,6 +25,8 @@ import (
"github.com/mailgun/mailgun-go/v4"
)
var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
// implements email sending, right now via smtp or mailgun.
type EmailClient interface {
Send(fromName, fromAddr string, message *Message, address ...string) error
@@ -337,7 +339,6 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Message, error) {
email := &Message{Subject: subject}
renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
html := markdown.ToHTML([]byte(md), nil, renderer)
text := stripMarkdown(md)
message := app.config.Section("messages").Key("message").String()
@@ -817,6 +818,12 @@ func (app *appContext) sendByID(email *Message, ID ...string) error {
return err
}
}
if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled {
err = app.matrix.Send(email, mxChat.RoomID)
if err != nil {
return err
}
}
if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled {
err = app.email.send(email, address.Addr)
if err != nil {

6
go.mod
View File

@@ -38,7 +38,9 @@ require (
github.com/lithammer/shortuuid/v3 v3.0.4
github.com/mailgun/mailgun-go/v4 v4.5.1
github.com/mailru/easyjson v0.7.7 // indirect
github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
github.com/swaggo/gin-swagger v1.3.0
@@ -47,8 +49,8 @@ require (
github.com/ugorji/go v1.2.0 // indirect
github.com/writeas/go-strip-markdown v2.0.1+incompatible
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea // indirect
golang.org/x/tools v0.1.1 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0

12
go.sum
View File

@@ -189,6 +189,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 h1:ZtO5uywdd5dLDCud4r0r55eP4j9FuUNpl60Gmntcop4=
github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16/go.mod h1:/gBX06Kw0exX1HrwmoBibFA98yBk/jxKpGVeyQbff+s=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -221,6 +223,8 @@ github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -306,6 +310,10 @@ golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210510120150-4163338589ed h1:p9UgmWI9wKpfYmgaV/IZKGdXc5qEK45tDwwwDyjS26I=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210521195947-fe42d452be8f h1:Si4U+UcgJzya9kpiEUJKQvjr512OLli+gL4poHrz93U=
golang.org/x/net v0.0.0-20210521195947-fe42d452be8f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -338,6 +346,10 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210521203332-0cec03c779c1 h1:lCnv+lfrU9FRPGf8NeRuWAAPjNnema5WtBinMgs1fD8=
golang.org/x/sys v0.0.0-20210521203332-0cec03c779c1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -8,6 +8,7 @@
window.emailEnabled = {{ .email_enabled }};
window.telegramEnabled = {{ .telegram_enabled }};
window.discordEnabled = {{ .discord_enabled }};
window.matrixEnabled = {{ .matrix_enabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
@@ -340,6 +341,19 @@
</div>
</div>
{{ end }}
<div id="modal-matrix" class="modal">
<form class="modal-content card" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="matrix-user">
<input type="password" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.password }}" id="matrix-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="notification-box"></div>
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
@@ -545,6 +559,9 @@
{{ if .telegram_enabled }}
<th>Telegram</th>
{{ end }}
{{ if .matrix_enabled }}
<th>Matrix</th>
{{ end }}
{{ if .discord_enabled }}
<th>Discord</th>
{{ end }}

View File

@@ -22,6 +22,9 @@
window.discordPIN = "{{ .discordPIN }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordServerName = "{{ .discordServerName }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
</script>
<script src="js/form.js" type="module"></script>
{{ end }}

View File

@@ -48,6 +48,24 @@
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .strings.linkMatrix }}</span>
<p class="content mb-1"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral !high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-1">
<span class="shield ~info mr-1">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info !normal full-width center mt-1" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
@@ -84,7 +102,10 @@
{{ if .discordEnabled }}
<span class="button ~info !normal full-width center mb-1" id="link-discord">{{ .strings.linkDiscord }}</span>
{{ end }}
{{ if or (.telegramEnabled) (.discordEnabled) }}
{{ if .matrixEnabled }}
<span class="button ~info !normal full-width center mb-1" id="link-matrix">{{ .strings.linkMatrix }}</span>
{{ end }}
{{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
<div id="contact-via" class="unfocused">
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="email"><span>Contact through Email</span>
@@ -99,6 +120,11 @@
<input type="radio" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
</label>
{{ end }}
{{ if .matrixEnabled }}
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
</label>
{{ end }}
</div>
{{ end }}
<label class="label supra" for="create-password">{{ .strings.password }}</label>

BIN
images/matrix/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
images/matrix/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
images/matrix/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
images/matrix/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -98,7 +98,9 @@
"notifyUserCreation": "On user creation",
"sendPIN": "Ask the user to send the PIN below to the bot.",
"searchDiscordUser": "Start typing the Discord username to find the user.",
"findDiscordUser": "Find Discord user"
"findDiscordUser": "Find Discord user",
"linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.",
"matrixHomeServer": "Home server address"
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",

View File

@@ -8,6 +8,7 @@
"emailAddress": "Email Address",
"name": "Name",
"submit": "Submit",
"send": "Send",
"success": "Success",
"error": "Error",
"copy": "Copy",
@@ -18,6 +19,7 @@
"contactEmail": "Contact through Email",
"contactTelegram": "Contact through Telegram",
"linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Contact through Discord",
"theme": "Theme"
}

View File

@@ -19,14 +19,17 @@
"confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}.",
"sendPIN": "Send the PIN below to the bot, then come back here to link your account.",
"sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below via DM to the bot."
"sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below via DM to the bot.",
"matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue."
},
"notifications": {
"errorUserExists": "User already exists.",
"errorInvalidCode": "Invalid invite code.",
"errorTelegramVerification": "Telegram verification required.",
"errorDiscordVerification": "Discord verification required.",
"errorMatrixVerification": "Matrix verification required.",
"errorInvalidPIN": "PIN is invalid.",
"errorUnknown": "Unknown error.",
"verified": "Account verified."
},
"validationStrings": {

View File

@@ -4,6 +4,7 @@
},
"strings": {
"startMessage": "Hi!\nEnter your Jellyfin PIN code here to verify your account.",
"matrixStartMessage": "Hi\nEnter the below PIN in the Jellyfin sign-up page to verify your account.",
"invalidPIN": "That PIN was invalid, try again.",
"pinSuccess": "Success! You can now return to the sign-up page.",
"languageMessage": "Note: See available languages with {command}, and set language with {command} <language code>."

52
main.go
View File

@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"mime"
@@ -20,6 +21,7 @@ import (
"time"
"github.com/fatih/color"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/logger"
@@ -58,6 +60,8 @@ var temp = func() string {
return temp
}()
var logPath string = filepath.Join(temp, "jfa-go.log")
var serverTypes = map[string]string{
"jellyfin": "Jellyfin",
"emby": "Emby (experimental)",
@@ -96,6 +100,7 @@ type appContext struct {
email *Emailer
telegram *TelegramDaemon
discord *DiscordDaemon
matrix *MatrixDaemon
info, debug, err logger.Logger
host string
port int
@@ -349,6 +354,10 @@ func start(asDaemon, firstCall bool) {
if err := app.storage.loadDiscordUsers(); err != nil {
app.err.Printf("Failed to load Discord users: %v", err)
}
app.storage.matrix_path = app.config.Section("files").Key("matrix_users").String()
if err := app.storage.loadMatrixUsers(); err != nil {
app.err.Printf("Failed to load Matrix users: %v", err)
}
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()
@@ -586,6 +595,16 @@ func start(asDaemon, firstCall bool) {
defer app.discord.Shutdown()
}
}
if matrixEnabled {
app.matrix, err = newMatrixDaemon(app)
if err != nil {
app.err.Printf("Failed to initialize Matrix daemon: %v", err)
matrixEnabled = false
} else {
go app.matrix.run()
defer app.matrix.Shutdown()
}
}
} else {
debugMode = false
address = "0.0.0.0:8056"
@@ -708,10 +727,41 @@ func flagPassed(name string) (found bool) {
// @tag.description Things that dont fit elsewhere.
func printVersion() {
fmt.Println(info("jfa-go version: %s (%s)\n", hiwhite(version), white(commit)))
tray := ""
if TRAY {
tray = " TrayIcon"
}
fmt.Println(info("jfa-go version: %s (%s)%s\n", hiwhite(version), white(commit), tray))
}
func logOutput() func() {
old := os.Stdout
log.Printf("Logging to \"%s\"", logPath)
f, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return func() {}
}
writer := io.MultiWriter(old, f)
r, w, _ := os.Pipe()
os.Stdout, os.Stderr = w, w
log.SetOutput(writer)
gin.DefaultWriter, gin.DefaultErrorWriter = writer, writer
wExit := make(chan bool)
go func() {
io.Copy(writer, r)
wExit <- true
}()
return func() {
w.Close()
<-wExit
f.Close()
}
}
func main() {
if TRAY {
defer logOutput()()
}
printVersion()
SOCK = filepath.Join(temp, SOCK)
fmt.Println("Socket:", SOCK)

238
matrix.go Normal file
View File

@@ -0,0 +1,238 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/gomarkdown/markdown"
"github.com/matrix-org/gomatrix"
)
type MatrixDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *gomatrix.Client
userID string
tokens map[string]UnverifiedUser // Map of tokens to users
languages map[string]string // Map of roomIDs to language codes
app *appContext
}
type UnverifiedUser struct {
Verified bool
User *MatrixUser
}
type MatrixUser struct {
RoomID string
UserID string
Lang string
Contact bool
}
type MatrixIdentifier struct {
User string `json:"user"`
IdentType string `json:"type"`
}
func (m MatrixIdentifier) Type() string { return m.IdentType }
var matrixFilter = gomatrix.Filter{
Room: gomatrix.RoomFilter{
Timeline: gomatrix.FilterPart{
Types: []string{
"m.room.message",
"m.room.member",
},
},
},
EventFields: []string{
"type",
"event_id",
"room_id",
"state_key",
"sender",
"content.body",
"content.membership",
},
}
func newMatrixDaemon(app *appContext) (d *MatrixDaemon, err error) {
matrix := app.config.Section("matrix")
homeserver := matrix.Key("homeserver").String()
token := matrix.Key("token").String()
d = &MatrixDaemon{
ShutdownChannel: make(chan string),
userID: matrix.Key("user_id").String(),
tokens: map[string]UnverifiedUser{},
languages: map[string]string{},
app: app,
}
d.bot, err = gomatrix.NewClient(homeserver, d.userID, token)
if err != nil {
return
}
filter, err := json.Marshal(matrixFilter)
if err != nil {
return
}
resp, err := d.bot.CreateFilter(filter)
d.bot.Store.SaveFilterID(d.userID, resp.FilterID)
for _, user := range app.storage.matrix {
if user.Lang != "" {
d.languages[user.RoomID] = user.Lang
}
}
return
}
func (d *MatrixDaemon) generateAccessToken(homeserver, username, password string) (string, error) {
req := &gomatrix.ReqLogin{
Type: "m.login.password",
Identifier: MatrixIdentifier{
User: username,
IdentType: "m.id.user",
},
Password: password,
DeviceID: "jfa-go-" + commit,
}
bot, err := gomatrix.NewClient(homeserver, username, "")
if err != nil {
return "", err
}
resp, err := bot.Login(req)
if err != nil {
return "", err
}
return resp.AccessToken, nil
}
func (d *MatrixDaemon) run() {
d.app.info.Println("Starting Matrix bot daemon")
syncer := d.bot.Syncer.(*gomatrix.DefaultSyncer)
syncer.OnEventType("m.room.message", d.handleMessage)
// syncer.OnEventType("m.room.member", d.handleMembership)
if err := d.bot.Sync(); err != nil {
d.app.err.Printf("Matrix sync failed: %v", err)
}
}
func (d *MatrixDaemon) Shutdown() {
d.bot.StopSync()
d.Stopped = true
close(d.ShutdownChannel)
}
func (d *MatrixDaemon) handleMessage(event *gomatrix.Event) {
if event.Sender == d.userID {
return
}
lang := "en-us"
if l, ok := d.languages[event.RoomID]; ok {
if _, ok := d.app.storage.lang.Telegram[l]; ok {
lang = l
}
}
sects := strings.Split(event.Content["body"].(string), " ")
switch sects[0] {
case "!lang":
if len(sects) == 2 {
d.commandLang(event, sects[1], lang)
} else {
d.commandLang(event, "", lang)
}
}
}
func (d *MatrixDaemon) commandLang(event *gomatrix.Event, code, lang string) {
if code == "" {
list := "!lang <lang>\n"
for c := range d.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", c, d.app.storage.lang.Telegram[c].Meta.Name)
}
_, err := d.bot.SendText(
event.RoomID,
list,
)
if err != nil {
d.app.err.Printf("Matrix: Failed to send message to \"%s\": %v", event.Sender, err)
}
return
}
if _, ok := d.app.storage.lang.Telegram[code]; !ok {
return
}
d.languages[event.RoomID] = code
if u, ok := d.app.storage.matrix[event.RoomID]; ok {
u.Lang = code
d.app.storage.matrix[event.RoomID] = u
if err := d.app.storage.storeMatrixUsers(); err != nil {
d.app.err.Printf("Matrix: Failed to store Matrix users: %v", err)
}
}
}
func (d *MatrixDaemon) CreateRoom(userID string) (string, error) {
room, err := d.bot.CreateRoom(&gomatrix.ReqCreateRoom{
Visibility: "private",
Invite: []string{userID},
Topic: d.app.config.Section("matrix").Key("topic").String(),
})
if err != nil {
return "", err
}
return room.RoomID, nil
}
func (d *MatrixDaemon) SendStart(userID string) (ok bool) {
roomID, err := d.CreateRoom(userID)
if err != nil {
d.app.err.Printf("Failed to create room for user \"%s\": %v", userID, err)
return
}
lang := "en-us"
pin := genAuthToken()
d.tokens[pin] = UnverifiedUser{
false,
&MatrixUser{
RoomID: roomID,
UserID: userID,
Lang: lang,
},
}
_, err = d.bot.SendText(
roomID,
d.app.storage.lang.Telegram[lang].Strings.get("matrixStartMessage")+"\n\n"+pin+"\n\n"+
d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"}),
)
if err != nil {
d.app.err.Printf("Matrix: Failed to send welcome message to \"%s\": %v", userID, err)
return
}
ok = true
return
}
func (d *MatrixDaemon) Send(message *Message, roomID ...string) (err error) {
md := ""
if message.Markdown != "" {
// Convert images to links
md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, renderer))
}
for _, id := range roomID {
if md != "" {
_, err = d.bot.SendFormattedText(id, message.Text, md)
} else {
_, err = d.bot.SendText(id, message.Text)
}
if err != nil {
return
}
}
return
}
// User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up.
// Message the user first, to avoid E2EE by default

View File

@@ -19,6 +19,8 @@ type newUserDTO struct {
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
DiscordPIN string `json:"discord_pin" example:"A1-B2-3C"` // Discord verification PIN (if used)
DiscordContact bool `json:"discord_contact"` // Whether or not to use discord for notifications/pwrs
MatrixPIN string `json:"matrix_pin" example:"A1-B2-3C"` // Matrix verification PIN (if used)
MatrixContact bool `json:"matrix_contact"` // Whether or not to use matrix for notifications/pwrs
}
type newUserResponse struct {
@@ -137,6 +139,8 @@ type respUser struct {
Discord string `json:"discord"` // Discord username (if known)
DiscordID string `json:"discord_id"` // Discord user ID for creating links.
NotifyThroughDiscord bool `json:"notify_discord"`
Matrix string `json:"matrix"` // Matrix ID (if known)
NotifyThroughMatrix bool `json:"notify_matrix"`
}
type getUsersDTO struct {
@@ -260,6 +264,7 @@ type SetContactMethodsDTO struct {
Email bool `json:"email"`
Discord bool `json:"discord"`
Telegram bool `json:"telegram"`
Matrix bool `json:"matrix"`
}
type DiscordUserDTO struct {
@@ -281,3 +286,22 @@ type DiscordInviteDTO struct {
InviteURL string `json:"invite"`
IconURL string `json:"icon"`
}
type MatrixSendPINDTO struct {
UserID string `json:"user_id"`
}
type MatrixCheckPINDTO struct {
PIN string `json:"pin"`
}
type MatrixConnectUserDTO struct {
JellyfinID string `json:"jf_id"`
UserID string `json:"user_id"`
}
type MatrixLoginDTO struct {
Homeserver string `json:"homeserver"`
Username string `json:"username"`
Password string `json:"password"`
}

View File

@@ -127,6 +127,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/invite/:invCode/discord/invite", app.DiscordServerInvite)
}
}
if matrixEnabled {
router.GET(p+"/invite/:invCode/matrix/verified/:userID/:pin", app.MatrixCheckPIN)
router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN)
router.POST(p+"/users/matrix", app.MatrixConnect)
}
}
if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@@ -164,7 +169,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
if telegramEnabled || discordEnabled {
if telegramEnabled || discordEnabled || matrixEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
api.POST(p+"/users/telegram", app.TelegramAddUser)
@@ -178,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/ombi/users", app.OmbiUsers)
api.POST(p+"/ombi/defaults", app.SetOmbiDefaults)
}
api.POST(p+"/matrix/login", app.MatrixLogin)
}
}

View File

@@ -15,21 +15,22 @@ import (
)
type Storage struct {
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path string
users map[string]time.Time
invites Invites
profiles map[string]Profile
defaultProfile string
displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users.
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
invitesLock, usersLock sync.Mutex
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path string
users map[string]time.Time
invites Invites
profiles map[string]Profile
defaultProfile string
displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users.
matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users.
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
invitesLock, usersLock sync.Mutex
}
type TelegramUser struct {
@@ -790,6 +791,14 @@ func (st *Storage) storeDiscordUsers() error {
return storeJSON(st.discord_path, st.discord)
}
func (st *Storage) loadMatrixUsers() error {
return loadJSON(st.matrix_path, &st.matrix)
}
func (st *Storage) storeMatrixUsers() error {
return storeJSON(st.matrix_path, st.matrix)
}
func (st *Storage) loadCustomEmails() error {
return loadJSON(st.customEmails_path, &st.customEmails)
}

View File

@@ -58,7 +58,7 @@ func genAuthToken() string {
rand.Seed(time.Now().UnixNano())
pin := make([]rune, 8)
for i := range pin {
if i == 2 || i == 5 {
if (i+1)%3 == 0 {
pin[i] = '-'
} else {
pin[i] = runes[rand.Intn(len(runes))]
@@ -146,6 +146,9 @@ func (t *TelegramDaemon) QuoteReply(upd *tg.Update, content string) error {
return err
}
var escapedChars = []string{"_", "\\_", "*", "\\*", "[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)", "~", "\\~", "`", "\\`", ">", "\\>", "#", "\\#", "+", "\\+", "-", "\\-", "=", "\\=", "|", "\\|", "{", "\\{", "}", "\\}", ".", "\\.", "!", "\\!"}
var escaper = strings.NewReplacer(escapedChars...)
// Send will send a telegram message to a list of chat IDs. message.text is used if no markdown is given.
func (t *TelegramDaemon) Send(message *Message, ID ...int64) error {
for _, id := range ID {
@@ -153,9 +156,7 @@ func (t *TelegramDaemon) Send(message *Message, ID ...int64) error {
if message.Markdown == "" {
msg = tg.NewMessage(id, message.Text)
} else {
text := strings.ReplaceAll(message.Markdown, ".", "\\.")
text = strings.ReplaceAll(text, "![", "[")
text = strings.ReplaceAll(text, "!", "\\!")
text := escaper.Replace(message.Markdown)
msg = tg.NewMessage(id, text)
msg.ParseMode = "MarkdownV2"
}

View File

@@ -9,6 +9,7 @@ import (
"syscall"
"github.com/getlantern/systray"
"github.com/skratchdot/open-golang/open"
// "github.com/getlantern/systray"
)
@@ -36,6 +37,7 @@ func onReady() {
mStart := systray.AddMenuItem("Start", "Start jfa-go")
mStop := systray.AddMenuItem("Stop", "Stop jfa-go")
mRestart := systray.AddMenuItem("Restart", "Restart jfa-go")
mOpenLogs := systray.AddMenuItem("Open logs", "Open jfa-go log file.")
as := NewAutostart("jfa-go", "A user management system for Jellyfin", "Run on login", "Run jfa-go on user login.")
mQuit := systray.AddMenuItem("Quit", "Quit jfa-go")
@@ -88,6 +90,8 @@ func onReady() {
mStop.Enable()
mRestart.Enable()
}
case <-mOpenLogs.ClickedCh:
open.Start(logPath)
case <-mQuit.ClickedCh:
systray.Quit()
// case <-mOnLogin.ClickedCh:

View File

@@ -63,6 +63,8 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.updateInfo = new Modal(document.getElementById("modal-update"));
window.modals.matrix = new Modal(document.getElementById("modal-matrix"));
if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
}

View File

@@ -1,6 +1,6 @@
import { Modal } from "./modules/modal.js";
import { notificationBox, whichAnimationEvent } from "./modules/common.js";
import { _get, _post, toggleLoader, toDateString } from "./modules/common.js";
import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
interface formWindow extends Window {
@@ -9,6 +9,7 @@ interface formWindow extends Window {
successModal: Modal;
telegramModal: Modal;
discordModal: Modal;
matrixModal: Modal;
confirmationModal: Modal
code: string;
messages: { [key: string]: string };
@@ -20,6 +21,8 @@ interface formWindow extends Window {
discordStartCommand: string;
discordInviteLink: boolean;
discordServerName: string;
matrixRequired: boolean;
matrixUserID: string;
userExpiryEnabled: boolean;
userExpiryMonths: number;
userExpiryDays: number;
@@ -150,6 +153,69 @@ if (window.discordEnabled) {
};
}
var matrixVerified = false;
var matrixPIN = "";
if (window.matrixEnabled) {
window.matrixModal = new Modal(document.getElementById("modal-matrix"), window.matrixRequired);
const matrixButton = document.getElementById("link-matrix") as HTMLSpanElement;
matrixButton.onclick = window.matrixModal.show;
const submitButton = document.getElementById("matrix-send") as HTMLSpanElement;
const input = document.getElementById("matrix-userid") as HTMLInputElement;
let userID = "";
submitButton.onclick = () => {
addLoader(submitButton);
if (userID == "") {
const send = {
user_id: input.value
};
_post("/invite/" + window.code + "/matrix/user", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
removeLoader(submitButton);
userID = input.value;
if (req.status != 200) {
window.notifications.customError("errorUnknown", window.messages["errorUnknown"]);
window.matrixModal.close();
return;
}
submitButton.classList.add("~positive");
submitButton.classList.remove("~info");
setTimeout(() => {
submitButton.classList.add("~info");
submitButton.classList.remove("~positive");
}, 2000);
input.placeholder = "PIN";
input.value = "";
}
});
} else {
_get("/invite/" + window.code + "/matrix/verified/" + userID + "/" + input.value, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
removeLoader(submitButton)
const valid = req.response["success"] as boolean;
if (valid) {
window.matrixModal.close();
window.notifications.customPositive("successVerified", "", window.messages["verified"]);
matrixVerified = true;
matrixPIN = input.value;
matrixButton.classList.add("unfocused");
document.getElementById("contact-via").classList.remove("unfocused");
const radio = document.getElementById("contact-via-discord") as HTMLInputElement;
radio.checked = true;
} else {
window.notifications.customError("errorInvalidPIN", window.messages["errorInvalidPIN"]);
submitButton.classList.add("~critical");
submitButton.classList.remove("~info");
setTimeout(() => {
submitButton.classList.add("~info");
submitButton.classList.remove("~critical");
}, 800);
}
}
},);
}
};
}
if (window.confirmation) {
window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true);
}
@@ -229,6 +295,8 @@ interface sendDTO {
telegram_contact?: boolean;
discord_pin?: string;
discord_contact?: boolean;
matrix_pin?: string;
matrix_contact?: boolean;
}
const create = (event: SubmitEvent) => {
@@ -254,6 +322,13 @@ const create = (event: SubmitEvent) => {
send.discord_contact = true;
}
}
if (matrixVerified) {
send.matrix_pin = matrixPIN;
const radio = document.getElementById("contact-via-matrix") as HTMLInputElement;
if (radio.checked) {
send.matrix_contact = true;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let vals = req.response as respDTO;

View File

@@ -18,6 +18,8 @@ interface User {
discord: string;
notify_discord: boolean;
discord_id: string;
matrix: string;
notify_matrix: boolean;
}
interface getPinResponse {
@@ -44,13 +46,27 @@ class user implements User {
private _discordUsername: string;
private _discordID: string;
private _notifyDiscord: boolean;
private _matrix: HTMLTableDataCellElement;
private _matrixID: string;
private _notifyMatrix: boolean;
private _expiry: HTMLTableDataCellElement;
private _expiryUnix: number;
private _lastActive: HTMLTableDataCellElement;
private _lastActiveUnix: number;
private _notifyDropdown: HTMLDivElement;
id = "";
private _selected: boolean;
private _lastNotifyMethod = (): string => {
// Telegram, Matrix, Discord
const telegram = this._telegramUsername && this._telegramUsername != "";
const discord = this._discordUsername && this._discordUsername != "";
const matrix = this._matrixID && this._matrixID != "";
if (discord) return "discord";
if (matrix) return "matrix";
if (telegram) return "telegram";
}
get selected(): boolean { return this._selected; }
set selected(state: boolean) {
this._selected = state;
@@ -96,105 +112,179 @@ class user implements User {
get notify_email(): boolean { return this._notifyEmail; }
set notify_email(s: boolean) {
this._notifyEmail = s;
if (window.telegramEnabled && this._telegramUsername != "") {
const email = this._telegram.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement;
if (email) {
email.checked = s;
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-email") as HTMLInputElement).checked = s;
}
}
private _constructDropdown = (): HTMLDivElement => {
const el = document.createElement("div") as HTMLDivElement;
const telegram = this._telegramUsername != "";
const discord = this._discordUsername != "";
const matrix = this._matrixID != "";
if (!telegram && !discord && !matrix) return;
let innerHTML = `
<i class="icon ri-settings-2-line ml-half dropdown-button"></i>
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="row switch pb-1 mt-half">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
</span>Email</span>
</label>
<div class="accounts-area-telegram">
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
</div>
<div class="accounts-area-discord">
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-discord">
<span>Discord</span>
</label>
</div>
<div class="accounts-area-matrix">
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-matrix">
<span>Matrix</span>
</label>
</div>
</div>
</div>
</div>
`;
el.innerHTML = innerHTML;
const button = el.querySelector("i");
const dropdown = el.querySelector("div.dropdown") as HTMLDivElement;
const checks = el.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
for (let i = 0; i < checks.length; i++) {
checks[i].onclick = () => this._setNotifyMethod();
}
button.onclick = () => {
dropdown.classList.add("selected");
document.addEventListener("click", outerClickListener);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (el.contains(event.target) || button.contains(event.target)))) {
dropdown.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
}
};
return el;
}
get matrix(): string { return this._matrixID; }
set matrix(u: string) {
if (!window.matrixEnabled) {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "matrix";
this._matrixID = u;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
this._matrix.innerHTML = `
<span class="chip btn !low">${window.lang.strings("add")}</span>
<input type="text" class="input ~neutral !normal stealth-input unfocused" placeholder="@user:riot.im">
`;
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
} else {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
this._matrix.innerHTML = `
<div class="table-inline">
${u}
</div>
`;
if (lastNotifyMethod) {
(this._matrix.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown);
}
}
if (window.discordEnabled && this._discordUsername) {
const email = this._discord.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement;
email.checked = s;
}
private _addMatrix = () => {
const addButton = this._matrix.querySelector(".btn") as HTMLSpanElement;
const icon = this._matrix.querySelector("i");
const input = this._matrix.querySelector("input.stealth-input") as HTMLInputElement;
if (addButton.classList.contains("chip")) {
input.classList.remove("unfocused");
addButton.innerHTML = `<i class="ri-check-line"></i>`;
addButton.classList.remove("chip")
if (icon) {
icon.classList.add("unfocused");
}
} else {
if (input.value.charAt(0) != "@" || !input.value.includes(":")) return;
const send = {
jf_id: this.id,
user_id: input.value
}
_post("/users/matrix", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
document.dispatchEvent(new CustomEvent("accounts-reload"));
if (req.status != 200) {
window.notifications.customError("errorConnectMatrix", window.lang.notif("errorFailureCheckLogs"));
return;
}
window.notifications.customSuccess("connectMatrix", window.lang.notif("accountConnected"));
}
});
}
}
get notify_matrix(): boolean { return this._notifyMatrix; }
set notify_matrix(s: boolean) {
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-matrix") as HTMLInputElement).checked = s;
}
}
get telegram(): string { return this._telegramUsername; }
set telegram(u: string) {
if (!window.telegramEnabled) return;
if (!window.telegramEnabled) {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "telegram";
this._telegramUsername = u;
if (u == "") {
this._telegram.innerHTML = `<span class="chip btn !low">Add</span>`;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
this._telegram.innerHTML = `<span class="chip btn !low">${window.lang.strings("add")}</span>`;
(this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram;
} else {
let innerHTML = `
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.remove("unfocused");
this._telegram.innerHTML = `
<div class="table-inline">
<a href="https://t.me/${u}" target="_blank">@${u}</a>
</div>
`;
if (!window.discordEnabled || !this._discordUsername) {
innerHTML += `
<i class="icon ri-settings-2-line ml-half dropdown-button"></i>
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="row switch pb-1 mt-half">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
<span>Email</span>
</label>
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
</div>
</div>
</div>
`;
}
innerHTML += "</div>";
this._telegram.innerHTML = innerHTML;
if (!window.discordEnabled || !this._discordUsername) {
// Javascript is necessary as including the button inside the dropdown would make it too wide to display next to the username.
const button = this._telegram.querySelector("i");
const dropdown = this._telegram.querySelector("div.dropdown") as HTMLDivElement;
const checks = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
for (let i = 0; i < checks.length; i++) {
checks[i].onclick = () => this._setNotifyMethod("telegram");
}
button.onclick = () => {
dropdown.classList.add("selected");
document.addEventListener("click", outerClickListener);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (this._telegram.contains(event.target) || button.contains(event.target)))) {
dropdown.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
}
};
if (lastNotifyMethod) {
(this._telegram.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown);
}
}
}
get notify_telegram(): boolean { return this._notifyTelegram; }
set notify_telegram(s: boolean) {
if (!window.telegramEnabled || !this._telegramUsername) return;
this._notifyTelegram = s;
const telegram = this._telegram.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
if (telegram) {
telegram.checked = s;
}
if (window.discordEnabled && this._discordUsername) {
const telegram = this._discord.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
telegram.checked = s;
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-telegram") as HTMLInputElement).checked = s;
}
}
private _setNotifyMethod = (mode: string = "telegram") => {
let el: HTMLElement;
if (mode == "telegram") { el = this._telegram }
else if (mode == "discord") { el = this._discord }
const email = el.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement;
private _setNotifyMethod = () => {
const email = this._notifyDropdown.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement;
let send = {
id: this.id,
email: email.checked
}
if (window.telegramEnabled && this._telegramUsername) {
const telegram = el.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
const telegram = this._notifyDropdown.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
send["telegram"] = telegram.checked;
}
if (window.discordEnabled && this._discordUsername) {
const discord = el.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement;
const discord = this._notifyDropdown.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement;
send["discord"] = discord.checked;
}
_post("/users/contact", send, (req: XMLHttpRequest) => {
@@ -219,62 +309,26 @@ class user implements User {
get discord(): string { return this._discordUsername; }
set discord(u: string) {
if (!window.discordEnabled) return;
if (!window.discordEnabled) {
this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "discord";
this._discordUsername = u;
if (u == "") {
if (!u) {
this._discord.innerHTML = `<span class="chip btn !low">Add</span>`;
(this._discord.querySelector("span") as HTMLSpanElement).onclick = () => addDiscord(this.id);
this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused");
} else {
let innerHTML = `
this._notifyDropdown.querySelector(".accounts-area-discord").classList.remove("unfocused");
this._discord.innerHTML = `
<div class="table-inline">
<a href="https://discord.com/users/${this._discordID}" class="discord-link" target="_blank">${u}</a>
<i class="icon ri-settings-2-line ml-half dropdown-button"></i>
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="row switch pb-1 mt-half">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
<span>Email</span>
</label>
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-discord">
<span>Discord</span>
</label>
`;
if (window.telegramEnabled && this._telegramUsername != "") {
innerHTML += `
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
`;
}
innerHTML += `
</div>
</div>
</div>
</div>
`;
this._discord.innerHTML = innerHTML;
// Javascript is necessary as including the button inside the dropdown would make it too wide to display next to the username.
const button = this._discord.querySelector("i");
const dropdown = this._discord.querySelector("div.dropdown") as HTMLDivElement;
const checks = this._discord.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
for (let i = 0; i < checks.length; i++) {
checks[i].onclick = () => this._setNotifyMethod("discord");
if (lastNotifyMethod) {
(this._discord.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown);
}
button.onclick = () => {
dropdown.classList.add("selected");
document.addEventListener("click", outerClickListener);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (this._discord.contains(event.target) || button.contains(event.target)))) {
dropdown.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
}
};
}
}
@@ -288,13 +342,8 @@ class user implements User {
get notify_discord(): boolean { return this._notifyDiscord; }
set notify_discord(s: boolean) {
if (!window.discordEnabled || !this._discordUsername) return;
this._notifyDiscord = s;
const discord = this._discord.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement;
discord.checked = s;
if (window.telegramEnabled && this._telegramUsername != "") {
const discord = this._discord.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement;
discord.checked = s;
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-discord") as HTMLInputElement).checked = s;
}
}
@@ -333,6 +382,11 @@ class user implements User {
<td class="accounts-telegram"></td>
`;
}
if (window.matrixEnabled) {
innerHTML += `
<td class="accounts-matrix"></td>
`;
}
if (window.discordEnabled) {
innerHTML += `
<td class="accounts-discord"></td>
@@ -352,10 +406,13 @@ class user implements User {
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement;
this._discord = this._row.querySelector(".accounts-discord") as HTMLTableDataCellElement;
this._matrix = this._row.querySelector(".accounts-matrix") as HTMLTableDataCellElement;
this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; }
this._notifyDropdown = this._constructDropdown();
const toggleStealthInput = () => {
if (this._emailEditButton.classList.contains("ri-edit-line")) {
this._email.innerHTML = emailEditor;
@@ -458,14 +515,20 @@ class user implements User {
this.id = user.id;
this.name = user.name;
this.email = user.email || "";
// Little hack to get settings cogs to appear on first load
this._discordUsername = user.discord;
this._telegramUsername = user.telegram;
this._matrixID = user.matrix;
this.discord = user.discord;
this.telegram = user.telegram;
this.matrix = user.matrix;
this.last_active = user.last_active;
this.admin = user.admin;
this.disabled = user.disabled;
this.expiry = user.expiry;
this.notify_discord = user.notify_discord;
this.notify_telegram = user.notify_telegram;
this.notify_matrix = user.notify_matrix;
this.notify_email = user.notify_email;
this.discord_id = user.discord_id;
}

View File

@@ -105,7 +105,11 @@ export class notificationBox implements NotificationBox {
private _error = (message: string): HTMLElement => {
const noti = document.createElement('aside');
noti.classList.add("aside", "~critical", "!normal", "mt-half", "notification-error");
noti.innerHTML = `<strong>${window.lang.strings("error")}:</strong> ${message}`;
let error = "";
if (window.lang) {
error = window.lang.strings("error") + ":"
}
noti.innerHTML = `<strong>${error}</strong> ${message}`;
const closeButton = document.createElement('span') as HTMLSpanElement;
closeButton.classList.add("button", "~critical", "!low", "ml-1");
closeButton.innerHTML = `<i class="icon ri-close-line"></i>`;

View File

@@ -1,4 +1,4 @@
import { _get, _post, toggleLoader } from "../modules/common.js";
import { _get, _post, toggleLoader, addLoader, removeLoader } from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
@@ -666,6 +666,40 @@ export class settingsList {
}
}
private _addMatrix = () => {
// Modify the login modal, why not
const modal = document.getElementById("form-matrix") as HTMLFormElement;
modal.onsubmit = (event: Event) => {
event.preventDefault();
const button = modal.querySelector("span.submit") as HTMLSpanElement;
addLoader(button);
let send = {
homeserver: (document.getElementById("matrix-homeserver") as HTMLInputElement).value,
username: (document.getElementById("matrix-user") as HTMLInputElement).value,
password: (document.getElementById("matrix-password") as HTMLInputElement).value
}
_post("/matrix/login", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
removeLoader(button);
if (req.status == 400) {
window.notifications.customError("errorUnknown", window.lang.notif(req.response["error"] as string));
return;
} else if (req.status == 401) {
window.notifications.customError("errorUnauthorized", req.response["error"] as string);
return;
} else if (req.status == 500) {
window.notifications.customError("errorAddMatrix", window.lang.notif("errorFailureCheckLogs"));
return;
}
window.modals.matrix.close();
_post("/restart", null, () => {});
window.location.reload();
}
}, true);
};
window.modals.matrix.show();
}
reload = () => _get("/config", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
@@ -698,6 +732,17 @@ export class settingsList {
icon.onclick = () => window.updater.checkForUpdates(window.modals.updateInfo.show);
}
this.addSection(name, settings.sections[name], icon);
} else if (name == "matrix" && !window.matrixEnabled) {
const addButton = document.createElement("div");
addButton.classList.add("tooltip", "left");
addButton.innerHTML = `
<span class="button ~neutral !normal">+</span>
<span class="content sm">
${window.lang.strings("linkMatrix")}
</span>
`;
(addButton.querySelector("span.button") as HTMLSpanElement).onclick = this._addMatrix;
this.addSection(name, settings.sections[name], addButton);
} else {
this.addSection(name, settings.sections[name]);
}

View File

@@ -22,6 +22,7 @@ declare interface Window {
emailEnabled: boolean;
telegramEnabled: boolean;
discordEnabled: boolean;
matrixEnabled: boolean;
ombiEnabled: boolean;
usernameEnabled: boolean;
token: string;
@@ -103,6 +104,7 @@ declare interface Modals {
updateInfo: Modal;
telegram: Modal;
discord: Modal;
matrix: Modal;
}
interface Invite {

View File

@@ -184,7 +184,6 @@ func (ud *Updater) GetTag() (Tag, int, error) {
return Tag{}, -1, nil
}
url := fmt.Sprintf("%s/repo/%s/%s/tag/latest/%s", ud.url, ud.namespace, ud.name, ud.tag)
fmt.Println(url)
req, _ := http.NewRequest("GET", url, nil)
resp, err := ud.httpClient.Do(req)
defer ud.timeoutHandler()

View File

@@ -123,6 +123,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"email_enabled": emailEnabled,
"telegram_enabled": telegramEnabled,
"discord_enabled": discordEnabled,
"matrix_enabled": matrixEnabled,
"notifications": notificationsEnabled,
"version": version,
"commit": commit,
@@ -287,6 +288,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"langName": lang,
"telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
}
if telegramEnabled {
data["telegramPIN"] = app.telegram.NewAuthToken()
@@ -294,6 +296,10 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
data["telegramURL"] = app.telegram.link
data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false)
}
if matrixEnabled {
data["matrixRequired"] = app.config.Section("matrix").Key("required").MustBool(false)
data["matrixUser"] = app.matrix.userID
}
if discordEnabled {
data["discordPIN"] = app.discord.NewAuthToken()
data["discordUsername"] = app.discord.username